arisa 5.1.68 → 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 (77) 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-processes.js +19 -3
  18. package/src/core/tools/daemon-protocol.js +72 -0
  19. package/src/core/tools/daemon-runtime.js +13 -490
  20. package/src/core/tools/daemon-worker.js +310 -0
  21. package/src/core/tools/ipc-client.js +2 -2
  22. package/src/core/tools/memory-pressure.js +56 -0
  23. package/src/core/tools/official-tool-installer.js +1 -1
  24. package/src/core/tools/tool-config.js +1 -1
  25. package/src/core/tools/tool-process-output.js +100 -0
  26. package/src/core/tools/tool-process-runner.js +175 -0
  27. package/src/core/tools/tool-registry.js +99 -187
  28. package/src/core/tools/tool-resource-note-store.js +1 -1
  29. package/src/core/tools/tool-usage-store.js +1 -1
  30. package/src/core/tools/weighted-resource-governor.js +188 -38
  31. package/src/index.js +14 -2
  32. package/src/official-tools.lock.json +424 -50
  33. package/src/platform/paths.js +152 -0
  34. package/src/runtime/bootstrap-cli.js +121 -0
  35. package/src/runtime/bootstrap-config.js +97 -0
  36. package/src/runtime/bootstrap-telegram.js +325 -0
  37. package/src/runtime/bootstrap.js +6 -543
  38. package/src/runtime/doctor.js +6 -3
  39. package/src/runtime/flush.js +1 -1
  40. package/src/runtime/ipc/ipc-server.js +1 -1
  41. package/src/runtime/log-viewer.js +1 -1
  42. package/src/runtime/oom-protection.js +20 -0
  43. package/src/runtime/paths.js +3 -151
  44. package/src/runtime/restart-receipt.js +1 -1
  45. package/src/runtime/service-manager.js +1 -1
  46. package/src/runtime/service-supervisor.js +14 -0
  47. package/src/runtime/slave-cli.js +1 -1
  48. package/src/runtime/tool-process-supervisor.js +1 -1
  49. package/src/runtime/tui.js +200 -0
  50. package/src/runtime/update-manager.js +1 -1
  51. package/src/runtime/worker-recovery-report.js +142 -0
  52. package/src/transport/telegram/bot.js +42 -320
  53. package/src/transport/telegram/prompt-builders.js +8 -3
  54. package/src/transport/telegram/telegram-prompt-controller.js +346 -0
  55. package/src/transport/telegram/workspace-topic-store.js +1 -1
  56. package/test/agent-session-lifecycle.test.js +92 -0
  57. package/test/architecture-boundaries.test.js +29 -0
  58. package/test/bootstrap.test.js +65 -0
  59. package/test/daemon-process-invocation.test.js +27 -0
  60. package/test/daemon-runtime.test.js +36 -1
  61. package/test/doctor.test.js +22 -0
  62. package/test/memory-pressure.test.js +36 -0
  63. package/test/model-selection.test.js +11 -1
  64. package/test/official-tool-dependencies.test.js +1 -1
  65. package/test/official-tool-installer.test.js +18 -1
  66. package/test/oom-protection.test.js +32 -0
  67. package/test/paths.test.js +7 -0
  68. package/test/pi-compaction.test.js +9 -0
  69. package/test/service-manager.test.js +6 -1
  70. package/test/telegram-prompt-controller.test.js +81 -0
  71. package/test/telegram-text-artifact.test.js +30 -0
  72. package/test/tool-registry-run.test.js +108 -4
  73. package/test/tui.test.js +41 -0
  74. package/test/weighted-resource-governor.test.js +97 -5
  75. package/test/worker-heap-circuit-breaker.test.js +79 -0
  76. package/test/worker-recovery-report.test.js +69 -0
  77. package/test-fixtures/fake-daemon.js +5 -0
@@ -0,0 +1,346 @@
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
+ if (ctx) return withTyping(ctx, work);
161
+ return work();
162
+ }
163
+
164
+ function processChatPromptQueue({ chatId, prompt, label, ctx = null, beforeInitialPrompt, initialReceipt = null }) {
165
+ const chatState = getChatState(chatId);
166
+ return drainChatPromptQueue({
167
+ chatState,
168
+ initialPrompt: prompt,
169
+ initialCtx: ctx,
170
+ initialReceipt,
171
+ beforeInitialPrompt,
172
+ processPrompt: ({ prompt: currentPrompt, ctx: currentCtx, receipt }) => {
173
+ logger?.log("telegram", `prompt dispatch for chat ${chatId}`);
174
+ return processPromptForChat({ chatId, prompt: currentPrompt, ctx: currentCtx, executionReceipt: receipt });
175
+ },
176
+ onPromptInterrupted: (error) => {
177
+ logger?.log("telegram", `${label} interrupted by queued /new for chat ${chatId}: ${getErrorMessage(error)}`);
178
+ },
179
+ onPromptFailure: async (error) => {
180
+ const message = getErrorMessage(error);
181
+ logger?.error("telegram", `${label} failed for chat ${chatId}: ${message}`);
182
+ await authController.notifyIssueIfNeeded(chatId, error);
183
+ }
184
+ });
185
+ }
186
+
187
+ async function enqueuePrompt({
188
+ chatId,
189
+ prompt,
190
+ label,
191
+ ctx = null,
192
+ replaceQueued = false,
193
+ busyMessageMode = "queue",
194
+ waitForExecution = false,
195
+ onExecutionStart = null,
196
+ coalesceQueued = false
197
+ }) {
198
+ const chatState = getChatState(chatId);
199
+ const receipt = waitForExecution ? createPromptExecutionReceipt(onExecutionStart) : null;
200
+
201
+ if (chatState.processing) {
202
+ const incomingRoute = ctx ? contextRoute(ctx) : null;
203
+ const activeRoute = chatState.activeRoute;
204
+ const sameDelivery = !incomingRoute || !activeRoute || (
205
+ incomingRoute.transportChatId === activeRoute.transportChatId
206
+ && incomingRoute.threadId === activeRoute.threadId
207
+ );
208
+ const routed = await routeBusyPrompt({
209
+ chatState,
210
+ prompt,
211
+ mode: sameDelivery ? busyMessageMode : "queue",
212
+ replaceQueued,
213
+ ctx,
214
+ receipt,
215
+ coalesceQueued
216
+ });
217
+ if (routed.disposition === "steered") {
218
+ logger?.log("telegram", `chat ${chatId} busy, steering ${label}`);
219
+ } else if (routed.disposition === "coalesced") {
220
+ logger?.log("telegram", `chat ${chatId} busy, coalescing ${label} into pending direct turn`);
221
+ } else {
222
+ logger?.log("telegram", `chat ${chatId} busy, queueing ${label}`);
223
+ if (routed.steerError) {
224
+ logger?.log("telegram", `steer failed for chat ${chatId}, queued instead: ${getErrorMessage(routed.steerError)}`);
225
+ }
226
+ }
227
+ if (replaceQueued) chatState.continueAfterClose = true;
228
+ return receipt ? receipt.promise : undefined;
229
+ }
230
+
231
+ chatState.processing = true;
232
+ logger?.log("telegram", `processing ${label} in chat ${chatId}`);
233
+ const draining = processChatPromptQueue({ chatId, prompt, label, ctx, initialReceipt: receipt });
234
+ if (!receipt) return draining;
235
+ draining.catch(() => {});
236
+ return receipt.promise;
237
+ }
238
+
239
+ async function enqueueOrProcess(ctx) {
240
+ const route = contextRoute(ctx);
241
+ const chatState = getChatState(route.sessionId);
242
+
243
+ if (chatState.processing) {
244
+ await ensureQueuedTyping(chatState, ctx);
245
+ const incomingPrompt = await buildIncomingPrompt(ctx, route);
246
+ const busyMessageMode = resolveBusyMessageMode({
247
+ config,
248
+ route,
249
+ message: ctx.message
250
+ });
251
+ return enqueuePrompt({
252
+ chatId: route.sessionId,
253
+ prompt: incomingPrompt,
254
+ label: `message ${ctx.msg.message_id}`,
255
+ busyMessageMode,
256
+ coalesceQueued: busyMessageMode === "steer" && typeof ctx.message?.text === "string",
257
+ ctx
258
+ });
259
+ }
260
+
261
+ const incomingPrompt = await buildIncomingPrompt(ctx, route);
262
+ return enqueuePrompt({
263
+ chatId: route.sessionId,
264
+ prompt: incomingPrompt,
265
+ label: `message ${ctx.msg.message_id}`,
266
+ ctx
267
+ });
268
+ }
269
+
270
+ async function summarizeSessionBeforeReset(chatId, route = directChatRoute(chatId)) {
271
+ let context;
272
+ try {
273
+ context = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(route), {
274
+ scopeChatId: route.scopeChatId,
275
+ accessGuard: createWorkspaceAccessGuard(route)
276
+ });
277
+ const parentSession = context.session.sessionFile || "";
278
+ if (!context.session.messages.length) return { handoff: "", parentSession: "" };
279
+
280
+ const summary = await collectText(context.session, buildSessionHandoffPrompt(), { logger, chatId });
281
+ return { handoff: sanitizeSessionHandoff(summary), parentSession };
282
+ } catch (error) {
283
+ logger?.log("agent", `session handoff summary failed for chat ${chatId}: ${getErrorMessage(error)}`);
284
+ return { handoff: "", parentSession: "" };
285
+ } finally {
286
+ await context?.release?.();
287
+ }
288
+ }
289
+
290
+ async function handleNewCommand(ctx) {
291
+ const route = contextRoute(ctx);
292
+ const sessionId = route.sessionId;
293
+ const chatState = getChatState(sessionId);
294
+ const wasProcessing = chatState.processing;
295
+ chatState.historyRevision += 1;
296
+ const commandRevision = chatState.historyRevision;
297
+ const prompt = buildNewSessionPrompt(ctx);
298
+
299
+ if (wasProcessing) {
300
+ logger?.log("telegram", `chat ${sessionId} busy, queueing new-session command`);
301
+ queueChatPrompt(chatState, prompt, { replace: true, ctx });
302
+ chatState.continueAfterClose = true;
303
+ const reset = (async () => {
304
+ await sessionSeeds.clear(sessionId);
305
+ agentManager.resetSession(sessionId);
306
+ })();
307
+ chatState.beforeNextPrompt = reset;
308
+ try {
309
+ await reset;
310
+ } finally {
311
+ if (chatState.beforeNextPrompt === reset) chatState.beforeNextPrompt = null;
312
+ }
313
+ return;
314
+ }
315
+
316
+ chatState.processing = true;
317
+ logger?.log("telegram", `processing new-session command in chat ${sessionId}`);
318
+ await processChatPromptQueue({
319
+ chatId: sessionId,
320
+ prompt,
321
+ label: "new-session command",
322
+ ctx,
323
+ beforeInitialPrompt: async () => {
324
+ const handoff = await withTyping(ctx, () => summarizeSessionBeforeReset(sessionId, route));
325
+ if (chatState.historyRevision !== commandRevision) return;
326
+ if (route.workspace && route.threadId) {
327
+ await sessionSeeds.set(sessionId, handoff.handoff);
328
+ } else {
329
+ await sessionSeeds.clear(sessionId);
330
+ }
331
+ if (chatState.historyRevision !== commandRevision) return;
332
+ agentManager.resetSession(sessionId, handoff);
333
+ }
334
+ });
335
+ }
336
+
337
+ return {
338
+ buildIncomingPrompt,
339
+ enqueueOrProcess,
340
+ enqueuePrompt,
341
+ handleNewCommand,
342
+ processChatPromptQueue,
343
+ processPromptForChat,
344
+ summarizeSessionBeforeReset
345
+ };
346
+ }
@@ -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,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
+ });
@@ -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
+ });
@@ -43,8 +43,16 @@ const {
43
43
  } = await import("../src/core/tools/daemon-processes.js");
44
44
  const {
45
45
  createDaemonRuntime,
46
- isDaemonReady
46
+ isDaemonReady,
47
+ submitDaemonControl,
48
+ DAEMON_EVENT_TYPES,
49
+ DAEMON_PROTOCOL_VERSION
47
50
  } = await import("../src/core/tools/daemon-runtime.js");
51
+ const { submitDaemonControl: directSubmitDaemonControl } = await import("../src/core/tools/daemon-client.js");
52
+ const {
53
+ DAEMON_EVENT_TYPES: directDaemonEventTypes,
54
+ DAEMON_PROTOCOL_VERSION: directDaemonProtocolVersion
55
+ } = await import("../src/core/tools/daemon-protocol.js");
48
56
  const { createToolProcessSupervisor, formatDaemonOutcome } = await import("../src/runtime/tool-process-supervisor.js");
49
57
  const { superviseDaemon } = await import("../src/core/tools/daemon-health.js");
50
58
  const { ToolRegistry } = await import("../src/core/tools/tool-registry.js");
@@ -78,6 +86,12 @@ test.after(async () => {
78
86
  await rm(homeDir, { recursive: true, force: true });
79
87
  });
80
88
 
89
+ test("preserves daemon client and protocol exports through the runtime facade", () => {
90
+ assert.equal(submitDaemonControl, directSubmitDaemonControl);
91
+ assert.equal(DAEMON_EVENT_TYPES, directDaemonEventTypes);
92
+ assert.equal(DAEMON_PROTOCOL_VERSION, directDaemonProtocolVersion);
93
+ });
94
+
81
95
  test("runs health through the queue before accepting jobs", async () => {
82
96
  const runtime = runtimeFor({ type: "global" });
83
97
  const output = await runtime.submit({ value: "hello" }, { timeoutMs: 1_000 });
@@ -117,6 +131,27 @@ test("streams ordered daemon events and persists the terminal result", async ()
117
131
  await runtime.stop();
118
132
  });
119
133
 
134
+ test("cancels a timed-out job without restarting the shared daemon", async () => {
135
+ const runtime = runtimeFor({ type: "global" });
136
+ const jobId = "job-cancelled-on-timeout";
137
+ await assert.rejects(
138
+ () => runtime.submit({ action: "hang-until-cancelled" }, { timeoutMs: 80, jobId }),
139
+ (error) => error.code === "DAEMON_JOB_TIMEOUT"
140
+ );
141
+
142
+ const terminal = await waitFor(async () => {
143
+ const result = await readJson(path.join(runtime.paths.commandsDir, `${jobId}.result.json`), null);
144
+ return result?.terminal || null;
145
+ });
146
+ assert.equal(terminal.type, "failed");
147
+ assert.equal(terminal.payload.code, "DAEMON_JOB_CANCELLED");
148
+ const pid = await runtime.getPid();
149
+ assert.equal(isProcessAlive(pid), true);
150
+ assert.deepEqual(await runtime.submit({ value: "after-timeout" }, { timeoutMs: 1_000 }), { echo: "after-timeout" });
151
+ assert.equal(await runtime.getPid(), pid);
152
+ await runtime.stop();
153
+ });
154
+
120
155
  test("deduplicates repeated notifications for one durable job id", async () => {
121
156
  const runtime = runtimeFor({ type: "global" });
122
157
  const jobId = "job-deduplicated";
@@ -151,6 +151,28 @@ test("stops only a registered duplicate Arisa service with verified identity", a
151
151
  assert.match(report.repairs.join("\n"), /Stopped duplicate Arisa service process 321/);
152
152
  });
153
153
 
154
+ test("does not stop the supervisor that owns the current worker", async () => {
155
+ const supervisorPid = 321;
156
+ const stopped = [];
157
+ const report = await runDoctor({
158
+ agentManager: { getRuntimeDiagnostic: async () => runtime() },
159
+ toolProcessSupervisor: { repair: async () => [] },
160
+ daemonPolicy,
161
+ doctorPolicy,
162
+ listProcesses: async () => [{
163
+ pid: supervisorPid,
164
+ command: `${process.execPath} ${serviceEntryFile} --service-runner`
165
+ }],
166
+ serviceStatus: async () => ({ running: true, pid: supervisorPid }),
167
+ stopProcess: async (pid) => { stopped.push(pid); },
168
+ inspectResources: async () => system,
169
+ supervisorPid
170
+ });
171
+
172
+ assert.deepEqual(stopped, []);
173
+ assert.deepEqual(report.repairs, []);
174
+ });
175
+
154
176
  test("requires complete positive doctor context policy", async () => {
155
177
  await assert.rejects(
156
178
  runDoctor({