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.
- package/README.md +7 -4
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +46 -6
- package/src/core/agent/agent-session-lifecycle.js +80 -3
- package/src/core/agent/core-tools.js +1 -1
- package/src/core/agent/pi-auth-login.js +1 -1
- package/src/core/agent/pi-runtime.js +1 -1
- package/src/core/agent/runtime-context.js +1 -1
- package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
- package/src/core/artifacts/artifact-store.js +1 -1
- package/src/core/capabilities/capability-service.js +1 -1
- package/src/core/config/config-defaults.js +30 -1
- package/src/core/config/config-store.js +1 -1
- package/src/core/conversation/session-seed-store.js +1 -1
- package/src/core/tasks/task-store.js +1 -1
- package/src/core/tools/daemon-client.js +180 -0
- package/src/core/tools/daemon-processes.js +19 -3
- package/src/core/tools/daemon-protocol.js +72 -0
- package/src/core/tools/daemon-runtime.js +13 -490
- package/src/core/tools/daemon-worker.js +310 -0
- package/src/core/tools/ipc-client.js +2 -2
- package/src/core/tools/memory-pressure.js +56 -0
- package/src/core/tools/official-tool-installer.js +1 -1
- package/src/core/tools/tool-config.js +1 -1
- package/src/core/tools/tool-process-output.js +100 -0
- package/src/core/tools/tool-process-runner.js +175 -0
- package/src/core/tools/tool-registry.js +99 -187
- package/src/core/tools/tool-resource-note-store.js +1 -1
- package/src/core/tools/tool-usage-store.js +1 -1
- package/src/core/tools/weighted-resource-governor.js +188 -38
- package/src/index.js +14 -2
- package/src/official-tools.lock.json +424 -50
- package/src/platform/paths.js +152 -0
- package/src/runtime/bootstrap-cli.js +121 -0
- package/src/runtime/bootstrap-config.js +97 -0
- package/src/runtime/bootstrap-telegram.js +325 -0
- package/src/runtime/bootstrap.js +6 -543
- package/src/runtime/doctor.js +6 -3
- package/src/runtime/flush.js +1 -1
- package/src/runtime/ipc/ipc-server.js +1 -1
- package/src/runtime/log-viewer.js +1 -1
- package/src/runtime/oom-protection.js +20 -0
- package/src/runtime/paths.js +3 -151
- package/src/runtime/restart-receipt.js +1 -1
- package/src/runtime/service-manager.js +1 -1
- package/src/runtime/service-supervisor.js +14 -0
- package/src/runtime/slave-cli.js +1 -1
- package/src/runtime/tool-process-supervisor.js +1 -1
- package/src/runtime/tui.js +200 -0
- package/src/runtime/update-manager.js +1 -1
- package/src/runtime/worker-recovery-report.js +142 -0
- package/src/transport/telegram/bot.js +42 -320
- package/src/transport/telegram/prompt-builders.js +8 -3
- package/src/transport/telegram/telegram-prompt-controller.js +346 -0
- package/src/transport/telegram/workspace-topic-store.js +1 -1
- package/test/agent-session-lifecycle.test.js +92 -0
- package/test/architecture-boundaries.test.js +29 -0
- package/test/bootstrap.test.js +65 -0
- package/test/daemon-process-invocation.test.js +27 -0
- package/test/daemon-runtime.test.js +36 -1
- package/test/doctor.test.js +22 -0
- package/test/memory-pressure.test.js +36 -0
- package/test/model-selection.test.js +11 -1
- package/test/official-tool-dependencies.test.js +1 -1
- package/test/official-tool-installer.test.js +18 -1
- package/test/oom-protection.test.js +32 -0
- package/test/paths.test.js +7 -0
- package/test/pi-compaction.test.js +9 -0
- package/test/service-manager.test.js +6 -1
- package/test/telegram-prompt-controller.test.js +81 -0
- package/test/telegram-text-artifact.test.js +30 -0
- package/test/tool-registry-run.test.js +108 -4
- package/test/tui.test.js +41 -0
- package/test/weighted-resource-governor.test.js +97 -5
- package/test/worker-heap-circuit-breaker.test.js +79 -0
- package/test/worker-recovery-report.test.js +69 -0
- 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 "../../
|
|
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";
|
package/test/doctor.test.js
CHANGED
|
@@ -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({
|