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.
- package/README.md +28 -6
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +123 -8
- package/src/core/agent/agent-session-lifecycle.js +108 -8
- package/src/core/agent/agent-turn-coordinator.js +143 -0
- package/src/core/agent/core-tools.js +1 -1
- package/src/core/agent/pi-auth-login.js +1 -1
- package/src/core/agent/pi-capability-tools.js +7 -4
- package/src/core/agent/pi-runtime.js +1 -1
- package/src/core/agent/runtime-context.js +1 -1
- package/src/core/agent/session-history-reader.js +168 -0
- package/src/core/agent/session-preload-migration.js +162 -0
- package/src/core/agent/session-rotation.js +29 -0
- package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
- package/src/core/agent/worker-tool-fanout.js +117 -0
- package/src/core/artifacts/artifact-store.js +1 -1
- package/src/core/capabilities/capability-service.js +4 -3
- package/src/core/config/config-defaults.js +59 -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-runner.js +8 -4
- package/src/core/tasks/task-store.js +48 -5
- package/src/core/tools/daemon-client.js +180 -0
- package/src/core/tools/daemon-processes.js +26 -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 +131 -191
- 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 +189 -38
- package/src/index.js +14 -2
- package/src/official-tools.lock.json +526 -78
- 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 +26 -76
- 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/obsolete-daemon-reaper.js +43 -0
- package/src/runtime/oom-protection.js +20 -0
- package/src/runtime/paths.js +3 -151
- package/src/runtime/process-inspection.js +78 -0
- 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 +36 -9
- 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 +45 -321
- package/src/transport/telegram/chat-queue.js +6 -2
- package/src/transport/telegram/prompt-builders.js +8 -3
- package/src/transport/telegram/task-dispatcher.js +36 -11
- package/src/transport/telegram/telegram-prompt-controller.js +359 -0
- package/src/transport/telegram/workspace-topic-store.js +1 -1
- package/test/agent-session-lifecycle.test.js +92 -0
- package/test/agent-turn-coordinator.test.js +45 -0
- package/test/architecture-boundaries.test.js +29 -0
- package/test/bootstrap.test.js +65 -0
- package/test/context-and-task-bounds.test.js +2 -1
- package/test/daemon-process-invocation.test.js +27 -0
- package/test/daemon-runtime.test.js +38 -5
- package/test/doctor.test.js +41 -0
- package/test/memory-pressure.test.js +41 -0
- package/test/model-selection.test.js +11 -1
- package/test/obsolete-daemon-reaper.test.js +61 -0
- package/test/official-tool-dependencies.test.js +7 -2
- 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 +30 -0
- package/test/service-manager.test.js +6 -1
- package/test/session-history-reader.test.js +84 -0
- package/test/session-preload-migration.test.js +120 -0
- package/test/session-rotation.test.js +110 -0
- package/test/task-store.test.js +32 -0
- package/test/telegram-prompt-controller.test.js +82 -0
- package/test/telegram-task-dispatcher.test.js +66 -5
- package/test/telegram-text-artifact.test.js +30 -0
- package/test/tool-registry-run.test.js +117 -4
- package/test/tui.test.js +41 -0
- package/test/weighted-resource-governor.test.js +125 -5
- package/test/worker-heap-circuit-breaker.test.js +79 -0
- package/test/worker-recovery-report.test.js +69 -0
- package/test/worker-tool-fanout.test.js +79 -0
- 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 "../../
|
|
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.
|
|
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
|
+
});
|