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
@@ -18,7 +18,7 @@ function nativeTools(policy) {
18
18
  }];
19
19
  }
20
20
 
21
- export function createPiCapabilityTools({ capabilityService, telegram, chatId, policy, logger }) {
21
+ export function createPiCapabilityTools({ capabilityService, telegram, chatId, policy, logger, toolFanout }) {
22
22
  if (!capabilityService?.execute) throw new Error("Pi capability tools require CapabilityService");
23
23
 
24
24
  const baseContext = {
@@ -38,6 +38,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
38
38
  }
39
39
  };
40
40
 
41
+ const runWithFanout = (work) => toolFanout?.run ? toolFanout.run(work) : work();
41
42
  const execute = (actorToolName, method, params = {}, context = {}) => capabilityService.execute({
42
43
  method,
43
44
  actorToolName,
@@ -113,12 +114,14 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
113
114
  args: Type.Optional(Type.Record(Type.String(), Type.String())),
114
115
  deliver: Type.Optional(Type.Boolean())
115
116
  }),
116
- execute: async (_id, params) => jsonResult(await execute("run_tool", "tools.run", params))
117
+ execute: async (_id, params) => jsonResult(await runWithFanout(
118
+ () => execute("run_tool", "tools.run", params)
119
+ ))
117
120
  }),
118
121
  defineTool({
119
122
  name: "list_scheduled_tasks",
120
123
  label: "List scheduled tasks",
121
- description: "List scheduled async tasks for the current Telegram chat. Results default to 50 tasks, always include pending/running tasks, and accept an optional limit up to 100.",
124
+ description: "List scheduled async tasks for the current Telegram chat. Results default to 50 tasks, always include pending, running, and authentication-blocked tasks, and accept an optional limit up to 100.",
122
125
  parameters: Type.Object({
123
126
  status: Type.Optional(Type.String()),
124
127
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: maxScheduledTaskListLimit }))
@@ -135,7 +138,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
135
138
  defineTool({
136
139
  name: "cancel_all_scheduled_tasks",
137
140
  label: "Cancel all scheduled tasks",
138
- description: "Cancel all pending or running async tasks for the current Telegram chat.",
141
+ description: "Cancel all active async tasks, including authentication-blocked tasks, for the current Telegram chat.",
139
142
  parameters: Type.Object({}),
140
143
  execute: async () => jsonResult(await execute("cancel_all_scheduled_tasks", "tasks.cancelAll"))
141
144
  }),
@@ -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,168 @@
1
+ import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { StringDecoder } from "node:string_decoder";
4
+
5
+ const readBufferBytes = 1024 * 1024;
6
+ const supportedSessionVersion = 3;
7
+
8
+ function parseEntry(line) {
9
+ if (!line.trim()) return null;
10
+ try {
11
+ return JSON.parse(line);
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function readSessionHeader(filePath) {
18
+ const descriptor = openSync(filePath, "r");
19
+ try {
20
+ const buffer = Buffer.alloc(4096);
21
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
22
+ const line = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
23
+ const header = parseEntry(line);
24
+ return header?.type === "session" ? header : null;
25
+ } catch {
26
+ return null;
27
+ } finally {
28
+ closeSync(descriptor);
29
+ }
30
+ }
31
+
32
+ export function findMostRecentSessionFile(sessionDir, cwd) {
33
+ const expectedCwd = path.resolve(cwd);
34
+ try {
35
+ return readdirSync(sessionDir)
36
+ .filter((name) => name.endsWith(".jsonl"))
37
+ .map((name) => path.join(sessionDir, name))
38
+ .map((filePath) => ({ filePath, header: readSessionHeader(filePath) }))
39
+ .filter(({ header }) => header && typeof header.cwd === "string" && path.resolve(header.cwd) === expectedCwd)
40
+ .map(({ filePath }) => ({ filePath, mtimeMs: statSync(filePath).mtimeMs }))
41
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.filePath || null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function streamSessionEntries(filePath, visit) {
48
+ const descriptor = openSync(filePath, "r");
49
+ try {
50
+ const decoder = new StringDecoder("utf8");
51
+ const buffer = Buffer.allocUnsafe(readBufferBytes);
52
+ let pending = "";
53
+ while (true) {
54
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.length, null);
55
+ if (!bytesRead) break;
56
+ pending += decoder.write(buffer.subarray(0, bytesRead));
57
+ let newline = pending.indexOf("\n");
58
+ while (newline !== -1) {
59
+ const entry = parseEntry(pending.slice(0, newline));
60
+ if (entry) visit(entry);
61
+ pending = pending.slice(newline + 1);
62
+ newline = pending.indexOf("\n");
63
+ }
64
+ }
65
+ pending += decoder.end();
66
+ const entry = parseEntry(pending);
67
+ if (entry) visit(entry);
68
+ } finally {
69
+ closeSync(descriptor);
70
+ }
71
+ }
72
+
73
+ function traceActivePath(entries, leafId) {
74
+ const reversed = [];
75
+ const seen = new Set();
76
+ let currentId = leafId;
77
+ while (currentId) {
78
+ if (seen.has(currentId)) return null;
79
+ seen.add(currentId);
80
+ const entry = entries.get(currentId);
81
+ if (!entry) return null;
82
+ reversed.push(entry);
83
+ currentId = entry.parentId || null;
84
+ }
85
+ return reversed.reverse();
86
+ }
87
+
88
+ function findLatestValidCompaction(path) {
89
+ const pathIndex = new Map(path.map((entry, index) => [entry.id, index]));
90
+ for (let index = path.length - 1; index >= 0; index -= 1) {
91
+ const entry = path[index];
92
+ if (entry.type !== "compaction" || !entry.hasSummary) continue;
93
+ if (!entry.firstKeptEntryId) return { entry, index, firstKeptIndex: index };
94
+ const firstKeptIndex = pathIndex.get(entry.firstKeptEntryId);
95
+ if (firstKeptIndex !== undefined && firstKeptIndex < index) {
96
+ return { entry, index, firstKeptIndex };
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function inspectSessionGraph(filePath) {
103
+ let header = null;
104
+ let firstEntry = true;
105
+ let invalidHeader = false;
106
+ let leafId = null;
107
+ let duplicateId = false;
108
+ const entries = new Map();
109
+ streamSessionEntries(filePath, (entry) => {
110
+ if (firstEntry) {
111
+ firstEntry = false;
112
+ header = entry.type === "session" ? entry : null;
113
+ invalidHeader = !header;
114
+ return;
115
+ }
116
+ if (invalidHeader) return;
117
+ if (!entry.id || entry.type === "session") return;
118
+ if (entries.has(entry.id)) duplicateId = true;
119
+ entries.set(entry.id, {
120
+ id: entry.id,
121
+ parentId: entry.parentId || null,
122
+ type: entry.type,
123
+ firstKeptEntryId: entry.type === "compaction" ? entry.firstKeptEntryId || null : null,
124
+ hasSummary: entry.type === "compaction" && Boolean(String(entry.summary || "").trim())
125
+ });
126
+ leafId = entry.id;
127
+ });
128
+ if (invalidHeader || !header || header.version !== supportedSessionVersion || duplicateId || !leafId) return null;
129
+ const path = traceActivePath(entries, leafId);
130
+ if (!path) return null;
131
+ const compaction = findLatestValidCompaction(path);
132
+ if (!compaction) return null;
133
+ return { header, path, compaction };
134
+ }
135
+
136
+ function loadMigrationPayload(filePath, path, compaction) {
137
+ const before = path.slice(compaction.firstKeptIndex, compaction.index);
138
+ const after = path.slice(compaction.index + 1);
139
+ const contextIds = [...before, ...after].map((entry) => entry.id);
140
+ const wanted = new Set(contextIds);
141
+ const loaded = new Map();
142
+ let summary = "";
143
+ streamSessionEntries(filePath, (entry) => {
144
+ if (entry.id === compaction.entry.id) summary = String(entry.summary || "").trim();
145
+ if (wanted.has(entry.id)) loaded.set(entry.id, entry);
146
+ });
147
+ if (!summary || loaded.size !== wanted.size) return null;
148
+ return {
149
+ summary,
150
+ contextEntries: contextIds.map((id) => loaded.get(id))
151
+ };
152
+ }
153
+
154
+ export function inspectSessionForPreloadMigration(filePath, maxPersistedBytes) {
155
+ const sourceBytes = statSync(filePath).size;
156
+ if (sourceBytes <= Math.max(1, Number(maxPersistedBytes) || 1)) return null;
157
+ const graph = inspectSessionGraph(filePath);
158
+ if (!graph) return null;
159
+ const payload = loadMigrationPayload(filePath, graph.path, graph.compaction);
160
+ if (!payload) return null;
161
+ return {
162
+ sourceFile: filePath,
163
+ sourceBytes,
164
+ header: graph.header,
165
+ compactionId: graph.compaction.entry.id,
166
+ ...payload
167
+ };
168
+ }
@@ -0,0 +1,162 @@
1
+ import {
2
+ closeSync,
3
+ fsyncSync,
4
+ openSync,
5
+ renameSync,
6
+ statSync,
7
+ unlinkSync,
8
+ writeFileSync
9
+ } from "node:fs";
10
+ import path from "node:path";
11
+ import { randomUUID } from "node:crypto";
12
+ import {
13
+ findMostRecentSessionFile,
14
+ inspectSessionForPreloadMigration
15
+ } from "./session-history-reader.js";
16
+ import { normalizeSessionRotationPolicy } from "./session-rotation.js";
17
+
18
+ const contextEntryTypes = new Set([
19
+ "message",
20
+ "custom_message",
21
+ "branch_summary",
22
+ "thinking_level_change",
23
+ "model_change"
24
+ ]);
25
+
26
+ function createEntry(type, parentId, fields = {}) {
27
+ return {
28
+ type,
29
+ ...fields,
30
+ id: randomUUID(),
31
+ parentId,
32
+ timestamp: fields.timestamp || new Date().toISOString()
33
+ };
34
+ }
35
+
36
+ function appendDurably(filePath, entry) {
37
+ writeFileSync(filePath, `${JSON.stringify(entry)}\n`, { encoding: "utf8", flag: "a" });
38
+ }
39
+
40
+ function syncFile(filePath) {
41
+ const descriptor = openSync(filePath, "r");
42
+ try {
43
+ fsyncSync(descriptor);
44
+ } finally {
45
+ closeSync(descriptor);
46
+ }
47
+ }
48
+
49
+ function syncDirectory(directory) {
50
+ const descriptor = openSync(directory, "r");
51
+ try {
52
+ fsyncSync(descriptor);
53
+ } finally {
54
+ closeSync(descriptor);
55
+ }
56
+ }
57
+
58
+ function migratedEntry(source, parentId) {
59
+ const { id: _id, parentId: _parentId, ...fields } = source;
60
+ return createEntry(source.type, parentId, fields);
61
+ }
62
+
63
+ export function createPreloadMigrationChild({
64
+ sessionDir,
65
+ cwd,
66
+ migration,
67
+ operationalNotes = "",
68
+ now = new Date()
69
+ }) {
70
+ const timestamp = now.toISOString();
71
+ const sessionId = randomUUID();
72
+ const filenameTimestamp = timestamp.replace(/[:.]/g, "-");
73
+ const targetFile = path.join(sessionDir, `${filenameTimestamp}_${sessionId}.jsonl`);
74
+ const temporaryFile = path.join(sessionDir, `.session-migration-${sessionId}.tmp`);
75
+ const header = {
76
+ type: "session",
77
+ version: 3,
78
+ id: sessionId,
79
+ timestamp,
80
+ cwd,
81
+ parentSession: migration.sourceFile
82
+ };
83
+ let parentId = null;
84
+ let copiedEntries = 0;
85
+
86
+ try {
87
+ writeFileSync(temporaryFile, `${JSON.stringify(header)}\n`, { encoding: "utf8", flag: "wx" });
88
+ const notes = String(operationalNotes || "").trim();
89
+ if (notes) {
90
+ const entry = createEntry("custom_message", parentId, {
91
+ customType: "arisa-operational-notes",
92
+ content: notes,
93
+ display: false,
94
+ details: { source: "session-start" }
95
+ });
96
+ appendDurably(temporaryFile, entry);
97
+ parentId = entry.id;
98
+ }
99
+ const handoff = createEntry("custom_message", parentId, {
100
+ customType: "arisa-session-handoff",
101
+ content: [
102
+ "Automatic session migration before loading. Continue from this checkpoint:",
103
+ "",
104
+ migration.summary
105
+ ].join("\n"),
106
+ display: false,
107
+ details: { source: "preload-migration" }
108
+ });
109
+ appendDurably(temporaryFile, handoff);
110
+ parentId = handoff.id;
111
+
112
+ for (const sourceEntry of migration.contextEntries) {
113
+ if (!contextEntryTypes.has(sourceEntry.type)) continue;
114
+ const entry = migratedEntry(sourceEntry, parentId);
115
+ appendDurably(temporaryFile, entry);
116
+ parentId = entry.id;
117
+ copiedEntries += 1;
118
+ }
119
+
120
+ syncFile(temporaryFile);
121
+ renameSync(temporaryFile, targetFile);
122
+ syncDirectory(sessionDir);
123
+ return {
124
+ sourceFile: migration.sourceFile,
125
+ sourceBytes: migration.sourceBytes,
126
+ targetFile,
127
+ targetBytes: statSync(targetFile).size,
128
+ copiedEntries
129
+ };
130
+ } catch (error) {
131
+ try {
132
+ unlinkSync(temporaryFile);
133
+ } catch {}
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ export function migrateRecentSessionBeforeLoad({
139
+ sessionDir,
140
+ cwd,
141
+ policy,
142
+ operationalNotes = ""
143
+ }) {
144
+ const normalized = normalizeSessionRotationPolicy(policy);
145
+ if (!normalized.enabled) return null;
146
+ const sourceFile = findMostRecentSessionFile(sessionDir, cwd);
147
+ if (!sourceFile) return null;
148
+ const sourceBytes = statSync(sourceFile).size;
149
+ if (sourceBytes <= normalized.maxPersistedBytes) return null;
150
+ const migration = inspectSessionForPreloadMigration(sourceFile, normalized.maxPersistedBytes);
151
+ if (!migration) {
152
+ const error = new Error("Oversized Pi session could not be migrated safely before loading");
153
+ error.code = "PI_SESSION_PRELOAD_MIGRATION_UNAVAILABLE";
154
+ throw error;
155
+ }
156
+ return createPreloadMigrationChild({
157
+ sessionDir,
158
+ cwd,
159
+ migration,
160
+ operationalNotes
161
+ });
162
+ }
@@ -0,0 +1,29 @@
1
+ const defaultCompactAtPersistedBytes = 24 * 1024 * 1024;
2
+ const defaultMaxPersistedBytes = 32 * 1024 * 1024;
3
+
4
+ export function normalizeSessionRotationPolicy(policy = {}) {
5
+ const maxPersistedBytes = Math.max(1, Number(policy?.maxPersistedBytes) || defaultMaxPersistedBytes);
6
+ return {
7
+ enabled: policy?.enabled !== false,
8
+ compactAtPersistedBytes: Math.min(
9
+ maxPersistedBytes,
10
+ Math.max(1, Number(policy?.compactAtPersistedBytes) || defaultCompactAtPersistedBytes)
11
+ ),
12
+ maxPersistedBytes
13
+ };
14
+ }
15
+
16
+ export function compactionRotationRequest(event, persistedBytes, policy = {}) {
17
+ const normalized = normalizeSessionRotationPolicy(policy);
18
+ if (!normalized.enabled || event?.type !== "compaction_end" || event.aborted || event.errorMessage) return null;
19
+ const summary = String(event.result?.summary || "").trim();
20
+ if (!summary || Math.max(0, Number(persistedBytes) || 0) <= normalized.compactAtPersistedBytes) return null;
21
+ return {
22
+ handoff: [
23
+ "Automatic session rotation after compaction. Continue from this checkpoint:",
24
+ "",
25
+ summary
26
+ ].join("\n"),
27
+ persistedBytes: Math.max(0, Number(persistedBytes) || 0)
28
+ };
29
+ }
@@ -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
+ }
@@ -0,0 +1,117 @@
1
+ const defaults = Object.freeze({
2
+ enabled: true,
3
+ maxConcurrent: 2,
4
+ pressureConcurrent: 1,
5
+ serializePercent: 60
6
+ });
7
+
8
+ function boundedInteger(value, fallback, minimum, maximum) {
9
+ const number = Number(value);
10
+ return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, Math.floor(number))) : fallback;
11
+ }
12
+
13
+ function boundedPercent(value, fallback) {
14
+ const number = Number(value);
15
+ return Number.isFinite(number) ? Math.min(99, Math.max(1, number)) : fallback;
16
+ }
17
+
18
+ export function normalizeToolFanoutConfig(config = {}) {
19
+ const maxConcurrent = boundedInteger(config.maxConcurrent, defaults.maxConcurrent, 1, 16);
20
+ return {
21
+ enabled: config.enabled !== false,
22
+ maxConcurrent,
23
+ pressureConcurrent: Math.min(
24
+ maxConcurrent,
25
+ boundedInteger(config.pressureConcurrent, defaults.pressureConcurrent, 1, 16)
26
+ ),
27
+ serializePercent: boundedPercent(config.serializePercent, defaults.serializePercent)
28
+ };
29
+ }
30
+
31
+ export class WorkerToolFanoutController {
32
+ constructor({ heapCircuitBreaker, logger, config = {} }) {
33
+ this.heapCircuitBreaker = heapCircuitBreaker;
34
+ this.logger = logger;
35
+ this.active = 0;
36
+ this.queue = [];
37
+ this.draining = false;
38
+ this.metrics = {
39
+ peakActive: 0,
40
+ peakQueued: 0,
41
+ pressureSerializations: 0,
42
+ rejectedAdmissions: 0,
43
+ completed: 0
44
+ };
45
+ this.setConfig(config);
46
+ }
47
+
48
+ setConfig(config = {}) {
49
+ this.config = normalizeToolFanoutConfig(config);
50
+ this.drain();
51
+ }
52
+
53
+ capacity() {
54
+ if (!this.config.enabled) return Number.POSITIVE_INFINITY;
55
+ const pressure = this.heapCircuitBreaker.sample();
56
+ if (pressure.percent >= this.config.serializePercent) {
57
+ this.metrics.pressureSerializations += 1;
58
+ return this.config.pressureConcurrent;
59
+ }
60
+ return this.config.maxConcurrent;
61
+ }
62
+
63
+ async drain() {
64
+ if (this.draining) return;
65
+ this.draining = true;
66
+ try {
67
+ while (this.queue.length && this.active < this.capacity()) {
68
+ const job = this.queue.shift();
69
+ try {
70
+ await this.heapCircuitBreaker.admit();
71
+ } catch (error) {
72
+ this.metrics.rejectedAdmissions += 1;
73
+ job.reject(error);
74
+ continue;
75
+ }
76
+ this.active += 1;
77
+ this.metrics.peakActive = Math.max(this.metrics.peakActive, this.active);
78
+ job.resolve();
79
+ }
80
+ } finally {
81
+ this.draining = false;
82
+ }
83
+ }
84
+
85
+ acquire() {
86
+ if (!this.config.enabled) return Promise.resolve();
87
+ return new Promise((resolve, reject) => {
88
+ this.queue.push({ resolve, reject });
89
+ this.metrics.peakQueued = Math.max(this.metrics.peakQueued, this.queue.length);
90
+ this.drain();
91
+ });
92
+ }
93
+
94
+ release() {
95
+ if (this.config.enabled) this.active = Math.max(0, this.active - 1);
96
+ this.metrics.completed += 1;
97
+ this.drain();
98
+ }
99
+
100
+ async run(work) {
101
+ await this.acquire();
102
+ try {
103
+ return await work();
104
+ } finally {
105
+ this.release();
106
+ }
107
+ }
108
+
109
+ getDiagnostic() {
110
+ return {
111
+ ...this.config,
112
+ active: this.active,
113
+ queued: this.queue.length,
114
+ ...this.metrics
115
+ };
116
+ }
117
+ }
@@ -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";
@@ -71,11 +71,12 @@ export function selectScheduledTasks(tasks = [], { status, limit = defaultSchedu
71
71
  maxScheduledTaskListLimit
72
72
  );
73
73
  const allTasks = Array.isArray(tasks) ? tasks : [];
74
+ const activeStatuses = new Set(["pending", "running", "blocked_auth"]);
74
75
  const orderedTasks = status
75
76
  ? [...allTasks].reverse()
76
77
  : [
77
- ...allTasks.filter((task) => task.status === "pending" || task.status === "running").reverse(),
78
- ...allTasks.filter((task) => task.status !== "pending" && task.status !== "running").reverse()
78
+ ...allTasks.filter((task) => activeStatuses.has(task.status)).reverse(),
79
+ ...allTasks.filter((task) => !activeStatuses.has(task.status)).reverse()
79
80
  ];
80
81
  const visibleTasks = orderedTasks.slice(0, resolvedLimit);
81
82
  return {