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
@@ -0,0 +1,359 @@
1
+ import { getErrorMessage } from "../../core/agent/auth-flow.js";
2
+ import { resolveChatSpeed } from "../../core/agent/model-selection.js";
3
+ import { captureIncomingArtifact } from "./media.js";
4
+ import {
5
+ appendGeneralReplyRoutingInstruction,
6
+ isGeneralWorkspaceRoute,
7
+ routeGeneralWorkspaceReply
8
+ } from "./reply-topic-routing.js";
9
+ import {
10
+ buildNewSessionPrompt,
11
+ buildPrompt,
12
+ buildSessionHandoffPrompt,
13
+ collectText,
14
+ normalizeIncomingArtifact,
15
+ sanitizeSessionHandoff,
16
+ scheduledPromptSpeedOptions,
17
+ withPromptSpeed
18
+ } from "./prompt-builders.js";
19
+ import {
20
+ createPromptExecutionReceipt,
21
+ drainChatPromptQueue,
22
+ queueChatPrompt,
23
+ routeBusyPrompt
24
+ } from "./chat-queue.js";
25
+
26
+ function directChatRoute(chatId) {
27
+ return {
28
+ workspace: false,
29
+ sessionId: String(chatId),
30
+ scopeChatId: chatId,
31
+ transportChatId: chatId,
32
+ threadId: null
33
+ };
34
+ }
35
+
36
+ export function createTelegramPromptController({
37
+ config,
38
+ api,
39
+ artifactStore,
40
+ toolRegistry,
41
+ agentManager,
42
+ sessionSeeds,
43
+ workspaceTopics,
44
+ logger,
45
+ contextRoute,
46
+ getChatState,
47
+ createTelegramSessionBridge,
48
+ createWorkspaceAccessGuard,
49
+ sendTextReply,
50
+ authController,
51
+ ensureWorkspaceTopicModelSelection,
52
+ ensureQueuedTyping,
53
+ withTyping,
54
+ resolveBusyMessageMode
55
+ }) {
56
+ async function buildIncomingPrompt(ctx, route = contextRoute(ctx)) {
57
+ logger?.log("telegram", `message ${ctx.msg.message_id} in chat ${route.transportChatId} session ${route.sessionId}`);
58
+ const chatArtifactStore = artifactStore.forChat(route.scopeChatId);
59
+ const artifact = await captureIncomingArtifact(ctx, artifactStore, { storageChatId: route.scopeChatId });
60
+ if (artifact) logger?.log("telegram", `captured artifact ${artifact.kind}${artifact.id ? ` ${artifact.id}` : ""}`);
61
+ const { transcript, toolResult, normalizationRequired } = await normalizeIncomingArtifact({
62
+ artifact,
63
+ toolRegistry,
64
+ chatArtifactStore,
65
+ chatId: route.scopeChatId
66
+ });
67
+ if (transcript) logger?.log("telegram", `media transcribed to artifact ${transcript.id}`);
68
+ if (normalizationRequired && !transcript) {
69
+ logger?.log("telegram", `media normalization unavailable for chat ${route.transportChatId}: ${toolResult?.error || toolResult?.missingConfig?.join(", ") || "unknown error"}`);
70
+ }
71
+ const prompt = buildPrompt({ ctx, artifact, transcript, toolResult });
72
+ if (!isGeneralWorkspaceRoute(route)) return prompt;
73
+ const [topics, recentProposals] = await Promise.all([
74
+ workspaceTopics.listTopics(route.ownerChatId, route.transportChatId),
75
+ workspaceTopics.listRecentProposals(route.ownerChatId, route.transportChatId)
76
+ ]);
77
+ return appendGeneralReplyRoutingInstruction(prompt, topics, recentProposals);
78
+ }
79
+
80
+ async function processPromptForChat({ chatId, prompt, ctx = null, executionReceipt = null }) {
81
+ const route = ctx ? contextRoute(ctx) : directChatRoute(chatId);
82
+ const sessionId = route.sessionId;
83
+ const bridge = createTelegramSessionBridge(route);
84
+ const messageOptions = (extra = {}) => route.workspace && route.threadId
85
+ ? { ...extra, message_thread_id: route.threadId }
86
+ : extra;
87
+ const work = async () => {
88
+ await ensureWorkspaceTopicModelSelection(route);
89
+ if (route.workspace && route.threadId) {
90
+ const handoff = await sessionSeeds.consume(sessionId);
91
+ if (handoff) {
92
+ agentManager.resetSession(sessionId, { handoff });
93
+ await agentManager.waitForSessionClose(sessionId);
94
+ }
95
+ }
96
+ const sessionContext = await agentManager.getSessionContext(sessionId, bridge, {
97
+ scopeChatId: route.scopeChatId,
98
+ accessGuard: createWorkspaceAccessGuard(route)
99
+ });
100
+ const { session, speedController } = sessionContext;
101
+ let text = "";
102
+ const chatState = getChatState(sessionId);
103
+ chatState.activeSession = session;
104
+ chatState.activeRoute = route;
105
+ try {
106
+ text = await withPromptSpeed(scheduledPromptSpeedOptions({
107
+ prompt,
108
+ session,
109
+ speedController,
110
+ configuredSpeed: resolveChatSpeed(config, sessionId)
111
+ }), () => collectText(session, prompt, {
112
+ logger,
113
+ chatId: sessionId,
114
+ onSlowPrompt: () => api.sendMessage(
115
+ route.transportChatId,
116
+ "This is taking longer than 5 minutes, so I will keep the current session running instead of starting over. Send /new if you want to abandon it and start fresh.",
117
+ messageOptions()
118
+ )
119
+ }));
120
+ } catch (error) {
121
+ agentManager.resetSession(sessionId);
122
+ if (error && typeof error === "object") {
123
+ error.retryable = false;
124
+ error.outcomeUncertain = true;
125
+ }
126
+ throw error;
127
+ } finally {
128
+ if (chatState.activeSession === session) chatState.activeSession = null;
129
+ chatState.activeRoute = null;
130
+ await sessionContext.release?.();
131
+ }
132
+ executionReceipt?.resolve({ status: "executed" });
133
+ if (!text) return;
134
+ const topics = route.workspace && route.ownerChatId
135
+ ? await workspaceTopics.listTopics(route.ownerChatId, route.transportChatId)
136
+ : [];
137
+ const routedReply = routeGeneralWorkspaceReply({ route, text, topics });
138
+ if (routedReply.proposal) {
139
+ await workspaceTopics.recordProposal(route.ownerChatId, route.transportChatId, routedReply.proposal);
140
+ logger?.log("telegram", `recorded topic proposal ${routedReply.proposal} for workspace ${route.transportChatId}`);
141
+ }
142
+ if (!routedReply.text) return;
143
+ const deliveryRoute = routedReply.route;
144
+ const deliveryOptions = (extra = {}) => deliveryRoute.workspace && deliveryRoute.threadId
145
+ ? { ...extra, message_thread_id: deliveryRoute.threadId }
146
+ : extra;
147
+ await createWorkspaceAccessGuard(deliveryRoute)();
148
+ if (routedReply.topic) {
149
+ logger?.log("telegram", `routing General reply to topic ${routedReply.topic.threadId} (${routedReply.topic.name})`);
150
+ }
151
+ await sendTextReply({
152
+ sendText: (message, extra) => api.sendMessage(deliveryRoute.transportChatId, message, deliveryOptions(extra)),
153
+ sendDocument: (file, extra) => api.sendDocument(deliveryRoute.transportChatId, file, deliveryOptions(extra)),
154
+ chatId: sessionId,
155
+ artifactChatId: deliveryRoute.scopeChatId,
156
+ text: routedReply.text
157
+ });
158
+ };
159
+
160
+ return agentManager.runTurn({
161
+ priority: executionReceipt?.priority || "interactive",
162
+ label: executionReceipt?.label || `interactive prompt for ${sessionId}`,
163
+ queueTtlMs: executionReceipt?.queueTtlMs
164
+ }, () => {
165
+ executionReceipt?.start?.();
166
+ return ctx ? withTyping(ctx, work) : work();
167
+ });
168
+ }
169
+
170
+ function processChatPromptQueue({ chatId, prompt, label, ctx = null, beforeInitialPrompt, initialReceipt = null }) {
171
+ const chatState = getChatState(chatId);
172
+ return drainChatPromptQueue({
173
+ chatState,
174
+ initialPrompt: prompt,
175
+ initialCtx: ctx,
176
+ initialReceipt,
177
+ beforeInitialPrompt,
178
+ processPrompt: ({ prompt: currentPrompt, ctx: currentCtx, receipt }) => {
179
+ logger?.log("telegram", `prompt dispatch for chat ${chatId}`);
180
+ return processPromptForChat({ chatId, prompt: currentPrompt, ctx: currentCtx, executionReceipt: receipt });
181
+ },
182
+ onPromptInterrupted: (error) => {
183
+ logger?.log("telegram", `${label} interrupted by queued /new for chat ${chatId}: ${getErrorMessage(error)}`);
184
+ },
185
+ onPromptFailure: async (error) => {
186
+ const message = getErrorMessage(error);
187
+ logger?.error("telegram", `${label} failed for chat ${chatId}: ${message}`);
188
+ await authController.notifyIssueIfNeeded(chatId, error);
189
+ }
190
+ });
191
+ }
192
+
193
+ async function enqueuePrompt({
194
+ chatId,
195
+ prompt,
196
+ label,
197
+ ctx = null,
198
+ replaceQueued = false,
199
+ busyMessageMode = "queue",
200
+ waitForExecution = false,
201
+ onExecutionStart = null,
202
+ coalesceQueued = false,
203
+ turnPriority = "interactive",
204
+ turnQueueTtlMs = undefined
205
+ }) {
206
+ const chatState = getChatState(chatId);
207
+ const receipt = waitForExecution ? createPromptExecutionReceipt(onExecutionStart, {
208
+ priority: turnPriority,
209
+ label,
210
+ queueTtlMs: turnQueueTtlMs,
211
+ deferStart: true
212
+ }) : null;
213
+
214
+ if (chatState.processing) {
215
+ const incomingRoute = ctx ? contextRoute(ctx) : null;
216
+ const activeRoute = chatState.activeRoute;
217
+ const sameDelivery = !incomingRoute || !activeRoute || (
218
+ incomingRoute.transportChatId === activeRoute.transportChatId
219
+ && incomingRoute.threadId === activeRoute.threadId
220
+ );
221
+ const routed = await routeBusyPrompt({
222
+ chatState,
223
+ prompt,
224
+ mode: sameDelivery ? busyMessageMode : "queue",
225
+ replaceQueued,
226
+ ctx,
227
+ receipt,
228
+ coalesceQueued
229
+ });
230
+ if (routed.disposition === "steered") {
231
+ logger?.log("telegram", `chat ${chatId} busy, steering ${label}`);
232
+ } else if (routed.disposition === "coalesced") {
233
+ logger?.log("telegram", `chat ${chatId} busy, coalescing ${label} into pending direct turn`);
234
+ } else {
235
+ logger?.log("telegram", `chat ${chatId} busy, queueing ${label}`);
236
+ if (routed.steerError) {
237
+ logger?.log("telegram", `steer failed for chat ${chatId}, queued instead: ${getErrorMessage(routed.steerError)}`);
238
+ }
239
+ }
240
+ if (replaceQueued) chatState.continueAfterClose = true;
241
+ return receipt ? receipt.promise : undefined;
242
+ }
243
+
244
+ chatState.processing = true;
245
+ logger?.log("telegram", `processing ${label} in chat ${chatId}`);
246
+ const draining = processChatPromptQueue({ chatId, prompt, label, ctx, initialReceipt: receipt });
247
+ if (!receipt) return draining;
248
+ draining.catch(() => {});
249
+ return receipt.promise;
250
+ }
251
+
252
+ async function enqueueOrProcess(ctx) {
253
+ const route = contextRoute(ctx);
254
+ const chatState = getChatState(route.sessionId);
255
+
256
+ if (chatState.processing) {
257
+ await ensureQueuedTyping(chatState, ctx);
258
+ const incomingPrompt = await buildIncomingPrompt(ctx, route);
259
+ const busyMessageMode = resolveBusyMessageMode({
260
+ config,
261
+ route,
262
+ message: ctx.message
263
+ });
264
+ return enqueuePrompt({
265
+ chatId: route.sessionId,
266
+ prompt: incomingPrompt,
267
+ label: `message ${ctx.msg.message_id}`,
268
+ busyMessageMode,
269
+ coalesceQueued: busyMessageMode === "steer" && typeof ctx.message?.text === "string",
270
+ ctx
271
+ });
272
+ }
273
+
274
+ const incomingPrompt = await buildIncomingPrompt(ctx, route);
275
+ return enqueuePrompt({
276
+ chatId: route.sessionId,
277
+ prompt: incomingPrompt,
278
+ label: `message ${ctx.msg.message_id}`,
279
+ ctx
280
+ });
281
+ }
282
+
283
+ async function summarizeSessionBeforeReset(chatId, route = directChatRoute(chatId)) {
284
+ let context;
285
+ try {
286
+ context = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(route), {
287
+ scopeChatId: route.scopeChatId,
288
+ accessGuard: createWorkspaceAccessGuard(route)
289
+ });
290
+ const parentSession = context.session.sessionFile || "";
291
+ if (!context.session.messages.length) return { handoff: "", parentSession: "" };
292
+
293
+ const summary = await collectText(context.session, buildSessionHandoffPrompt(), { logger, chatId });
294
+ return { handoff: sanitizeSessionHandoff(summary), parentSession };
295
+ } catch (error) {
296
+ logger?.log("agent", `session handoff summary failed for chat ${chatId}: ${getErrorMessage(error)}`);
297
+ return { handoff: "", parentSession: "" };
298
+ } finally {
299
+ await context?.release?.();
300
+ }
301
+ }
302
+
303
+ async function handleNewCommand(ctx) {
304
+ const route = contextRoute(ctx);
305
+ const sessionId = route.sessionId;
306
+ const chatState = getChatState(sessionId);
307
+ const wasProcessing = chatState.processing;
308
+ chatState.historyRevision += 1;
309
+ const commandRevision = chatState.historyRevision;
310
+ const prompt = buildNewSessionPrompt(ctx);
311
+
312
+ if (wasProcessing) {
313
+ logger?.log("telegram", `chat ${sessionId} busy, queueing new-session command`);
314
+ queueChatPrompt(chatState, prompt, { replace: true, ctx });
315
+ chatState.continueAfterClose = true;
316
+ const reset = (async () => {
317
+ await sessionSeeds.clear(sessionId);
318
+ agentManager.resetSession(sessionId);
319
+ })();
320
+ chatState.beforeNextPrompt = reset;
321
+ try {
322
+ await reset;
323
+ } finally {
324
+ if (chatState.beforeNextPrompt === reset) chatState.beforeNextPrompt = null;
325
+ }
326
+ return;
327
+ }
328
+
329
+ chatState.processing = true;
330
+ logger?.log("telegram", `processing new-session command in chat ${sessionId}`);
331
+ await processChatPromptQueue({
332
+ chatId: sessionId,
333
+ prompt,
334
+ label: "new-session command",
335
+ ctx,
336
+ beforeInitialPrompt: async () => {
337
+ const handoff = await withTyping(ctx, () => summarizeSessionBeforeReset(sessionId, route));
338
+ if (chatState.historyRevision !== commandRevision) return;
339
+ if (route.workspace && route.threadId) {
340
+ await sessionSeeds.set(sessionId, handoff.handoff);
341
+ } else {
342
+ await sessionSeeds.clear(sessionId);
343
+ }
344
+ if (chatState.historyRevision !== commandRevision) return;
345
+ agentManager.resetSession(sessionId, handoff);
346
+ }
347
+ });
348
+ }
349
+
350
+ return {
351
+ buildIncomingPrompt,
352
+ enqueueOrProcess,
353
+ enqueuePrompt,
354
+ handleNewCommand,
355
+ processChatPromptQueue,
356
+ processPromptForChat,
357
+ summarizeSessionBeforeReset
358
+ };
359
+ }
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { getChatTelegramWorkspacesFile } from "../../runtime/paths.js";
3
+ import { getChatTelegramWorkspacesFile } from "../../platform/paths.js";
4
4
 
5
5
  const recentProposalWindowMs = 30 * 24 * 60 * 60 * 1000;
6
6
 
@@ -46,8 +46,17 @@ test("session lifecycle diagnostics remain available after extraction", async ()
46
46
  harness: "pi",
47
47
  sessions: 1,
48
48
  closingSessions: 0,
49
+ cache: {
50
+ maxSessions: 3,
51
+ maxPersistedBytes: 48 * 1024 * 1024,
52
+ sessions: 1,
53
+ persistedBytes: 0
54
+ },
49
55
  contexts: [{
50
56
  chatId: "123",
57
+ activeUsers: 0,
58
+ persistedBytes: 0,
59
+ lastAccessedAt: null,
51
60
  messages: 2,
52
61
  estimatedTokens: 42,
53
62
  tokens: 42,
@@ -56,3 +65,86 @@ test("session lifecycle diagnostics remain available after extraction", async ()
56
65
  }]
57
66
  });
58
67
  });
68
+
69
+ test("evicts the least recently used inactive session without touching active work", async () => {
70
+ const closed = [];
71
+ const lifecycle = new AgentSessionLifecycle({
72
+ logger: null,
73
+ summarizeContext: () => ({}),
74
+ cachePolicy: { maxSessions: 2, maxPersistedBytes: 1_000 }
75
+ });
76
+ lifecycle.sessions.set("active-old", {
77
+ activeUsers: 1,
78
+ lastAccessedAt: 1,
79
+ persistedBytes: 100,
80
+ session: { async close() { closed.push("active-old"); } }
81
+ });
82
+ lifecycle.sessions.set("inactive-old", {
83
+ activeUsers: 0,
84
+ lastAccessedAt: 2,
85
+ persistedBytes: 100,
86
+ session: { async close() { closed.push("inactive-old"); } }
87
+ });
88
+ lifecycle.sessions.set("current", {
89
+ activeUsers: 1,
90
+ lastAccessedAt: 3,
91
+ persistedBytes: 100,
92
+ session: { async close() { closed.push("current"); } }
93
+ });
94
+
95
+ const evicted = await lifecycle.enforceCachePolicy({ protectedSessionKeys: ["current"] });
96
+
97
+ assert.deepEqual(evicted, [{ sessionKey: "inactive-old", persistedBytes: 100 }]);
98
+ assert.deepEqual(closed, ["inactive-old"]);
99
+ assert.deepEqual([...lifecycle.sessions.keys()], ["active-old", "current"]);
100
+ });
101
+
102
+ test("pressure eviction closes every inactive session while preserving active work", async () => {
103
+ const closed = [];
104
+ const lifecycle = new AgentSessionLifecycle({
105
+ logger: null,
106
+ summarizeContext: () => ({})
107
+ });
108
+ lifecycle.sessions.set("active", {
109
+ activeUsers: 1,
110
+ lastAccessedAt: 1,
111
+ session: { async close() { closed.push("active"); } }
112
+ });
113
+ lifecycle.sessions.set("idle-old", {
114
+ lastAccessedAt: 2,
115
+ session: { async close() { closed.push("idle-old"); } }
116
+ });
117
+ lifecycle.sessions.set("idle-new", {
118
+ lastAccessedAt: 3,
119
+ session: { async close() { closed.push("idle-new"); } }
120
+ });
121
+
122
+ const evicted = await lifecycle.evictInactive();
123
+
124
+ assert.deepEqual(evicted.map((item) => item.sessionKey), ["idle-old", "idle-new"]);
125
+ assert.deepEqual(closed, ["idle-old", "idle-new"]);
126
+ assert.deepEqual([...lifecycle.sessions.keys()], ["active"]);
127
+ });
128
+
129
+ test("uses persisted session weight as a second cache bound", async () => {
130
+ const lifecycle = new AgentSessionLifecycle({
131
+ logger: null,
132
+ summarizeContext: () => ({}),
133
+ cachePolicy: { maxSessions: 10, maxPersistedBytes: 100 }
134
+ });
135
+ lifecycle.sessions.set("large-old", {
136
+ lastAccessedAt: 1,
137
+ persistedBytes: 80,
138
+ session: { close() {} }
139
+ });
140
+ lifecycle.sessions.set("recent", {
141
+ lastAccessedAt: 2,
142
+ persistedBytes: 40,
143
+ session: { close() {} }
144
+ });
145
+
146
+ await lifecycle.enforceCachePolicy({ protectedSessionKeys: ["recent"] });
147
+
148
+ assert.deepEqual([...lifecycle.sessions.keys()], ["recent"]);
149
+ assert.deepEqual(lifecycle.cacheUsage(), { sessions: 1, persistedBytes: 40 });
150
+ });
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { AgentTurnCoordinator } from "../src/core/agent/agent-turn-coordinator.js";
4
+
5
+ test("interactive turns run before queued background turns without overlapping", async () => {
6
+ const coordinator = new AgentTurnCoordinator();
7
+ const releaseActive = await coordinator.acquire({ priority: "background", label: "active background" });
8
+ let backgroundStarted = false;
9
+ const background = coordinator.acquire({ priority: "background", label: "queued background" }).then((release) => {
10
+ backgroundStarted = true;
11
+ return release;
12
+ });
13
+ const interactive = coordinator.acquire({ priority: "interactive", label: "interactive" });
14
+
15
+ releaseActive();
16
+ const releaseInteractive = await interactive;
17
+ assert.equal(backgroundStarted, false);
18
+ assert.equal(coordinator.diagnostic().active.priority, "interactive");
19
+ releaseInteractive();
20
+ const releaseBackground = await background;
21
+ assert.equal(backgroundStarted, true);
22
+ releaseBackground();
23
+ await new Promise((resolve) => setImmediate(resolve));
24
+ assert.equal(coordinator.diagnostic().active, null);
25
+ assert.equal(coordinator.diagnostic().completed, 3);
26
+ });
27
+
28
+ test("background turns expire safely before execution when their queue TTL elapses", async () => {
29
+ const coordinator = new AgentTurnCoordinator();
30
+ const releaseActive = await coordinator.acquire({ priority: "interactive", label: "active" });
31
+ await assert.rejects(
32
+ coordinator.acquire({ priority: "background", label: "stale batch", queueTtlMs: 10 }),
33
+ (error) => error.code === "AGENT_TURN_QUEUE_EXPIRED" && error.retryable === true && error.outcomeUncertain === false
34
+ );
35
+ assert.equal(coordinator.diagnostic().expired, 1);
36
+ releaseActive();
37
+ });
38
+
39
+ test("run releases exclusive admission after failures", async () => {
40
+ const coordinator = new AgentTurnCoordinator();
41
+ await assert.rejects(coordinator.run({ priority: "background" }, async () => { throw new Error("failed"); }), /failed/);
42
+ const result = await coordinator.run({ priority: "interactive" }, async () => "ok");
43
+ assert.equal(result, "ok");
44
+ assert.equal(coordinator.diagnostic().completed, 2);
45
+ });
@@ -0,0 +1,29 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile, readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
+ const coreDir = path.join(packageDir, "src", "core");
9
+
10
+ async function javascriptFiles(directory) {
11
+ const entries = await readdir(directory, { withFileTypes: true });
12
+ const nested = await Promise.all(entries.map((entry) => {
13
+ const target = path.join(directory, entry.name);
14
+ if (entry.isDirectory()) return javascriptFiles(target);
15
+ return entry.isFile() && entry.name.endsWith(".js") ? [target] : [];
16
+ }));
17
+ return nested.flat();
18
+ }
19
+
20
+ test("core does not depend on the runtime composition layer", async () => {
21
+ const violations = [];
22
+ for (const file of await javascriptFiles(coreDir)) {
23
+ const source = await readFile(file, "utf8");
24
+ if (/from\s+["'][^"']*\/runtime\//.test(source)) {
25
+ violations.push(path.relative(packageDir, file));
26
+ }
27
+ }
28
+ assert.deepEqual(violations, []);
29
+ });
@@ -0,0 +1,65 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildConfig } from "../src/runtime/bootstrap.js";
4
+ import {
5
+ buildBootstrapConfig,
6
+ parseYesNo,
7
+ selectByIndex,
8
+ selectPiLoginOption,
9
+ sortBootstrapModels,
10
+ sortBootstrapProviders
11
+ } from "../src/runtime/bootstrap-config.js";
12
+
13
+
14
+ test("bootstrap facade preserves the shared config builder", () => {
15
+ assert.equal(buildConfig, buildBootstrapConfig);
16
+ const config = buildConfig({
17
+ telegramApiKey: "telegram-token",
18
+ telegramMaxChatIds: 2,
19
+ authorizedChatIds: [123],
20
+ chatMeta: { 123: { languageCode: "es" } },
21
+ provider: "openai-codex",
22
+ model: "gpt-5.5",
23
+ piApiKey: "pi-key"
24
+ });
25
+
26
+ assert.equal(config.telegram.token, "telegram-token");
27
+ assert.equal(config.telegram.maxChatIds, 2);
28
+ assert.deepEqual(config.telegram.authorizedChatIds, [123]);
29
+ assert.equal(config.pi.provider, "openai-codex");
30
+ assert.equal(config.pi.model, "gpt-5.5");
31
+ assert.equal(config.pi.apiKey, "pi-key");
32
+ });
33
+
34
+ test("shared bootstrap policy keeps provider and model ordering stable", () => {
35
+ const providers = [
36
+ { provider: "anthropic" },
37
+ { provider: "openai-codex" },
38
+ { provider: "google" }
39
+ ];
40
+ assert.deepEqual(
41
+ sortBootstrapProviders(providers).map((item) => item.provider),
42
+ ["openai-codex", "anthropic", "google"]
43
+ );
44
+ assert.deepEqual(providers.map((item) => item.provider), ["anthropic", "openai-codex", "google"]);
45
+
46
+ const models = [{ id: "older" }, { id: "gpt-5.5" }, { id: "newer" }];
47
+ assert.deepEqual(
48
+ sortBootstrapModels("openai-codex", models).map((item) => item.id),
49
+ ["gpt-5.5", "newer", "older"]
50
+ );
51
+ assert.equal(selectByIndex(models, "99"), models[2]);
52
+ });
53
+
54
+ test("shared bootstrap input policy preserves localized yes/no and login preference", () => {
55
+ assert.equal(parseYesNo("sí"), true);
56
+ assert.equal(parseYesNo("no"), false);
57
+ assert.equal(parseYesNo("maybe"), null);
58
+ assert.equal(parseYesNo("", false), false);
59
+
60
+ const selected = selectPiLoginOption([
61
+ { id: "browser", label: "Browser OAuth" },
62
+ { id: "device", label: "Device code" }
63
+ ]);
64
+ assert.equal(selected.id, "device");
65
+ });
@@ -395,6 +395,7 @@ test("selectScheduledTasks bounds history while keeping active tasks", () => {
395
395
  status: "done"
396
396
  }));
397
397
  tasks[0] = { id: "pending-1", status: "pending" };
398
+ tasks[1] = { id: "blocked-1", status: "blocked_auth" };
398
399
 
399
400
  const result = selectScheduledTasks(tasks);
400
401
 
@@ -402,7 +403,7 @@ test("selectScheduledTasks bounds history while keeping active tasks", () => {
402
403
  assert.equal(result.returned, 50);
403
404
  assert.equal(result.limit, 50);
404
405
  assert.equal(result.truncated, true);
405
- assert.equal(result.tasks[0].id, "pending-1");
406
+ assert.deepEqual(result.tasks.slice(0, 2).map((task) => task.id), ["blocked-1", "pending-1"]);
406
407
  });
407
408
 
408
409
  test("selectScheduledTasks honors an explicit status and limit", () => {
@@ -0,0 +1,27 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { daemonProcessInvocation } from "../src/core/tools/daemon-processes.js";
4
+
5
+ test("gives Linux tool daemons a higher OOM kill priority than core", () => {
6
+ assert.deepEqual(daemonProcessInvocation("/tool/index.js", {
7
+ platform: "linux",
8
+ nodePath: "/usr/bin/node",
9
+ oomAdjustAvailable: true
10
+ }), {
11
+ command: "/usr/bin/choom",
12
+ args: ["-n", "500", "--", "/usr/bin/node", "/tool/index.js", "daemon"],
13
+ oomProtected: true
14
+ });
15
+ });
16
+
17
+ test("starts tool daemons directly when OOM adjustment is unavailable", () => {
18
+ assert.deepEqual(daemonProcessInvocation("/tool/index.js", {
19
+ platform: "darwin",
20
+ nodePath: "/usr/bin/node",
21
+ oomAdjustAvailable: false
22
+ }), {
23
+ command: "/usr/bin/node",
24
+ args: ["/tool/index.js", "daemon"],
25
+ oomProtected: false
26
+ });
27
+ });