arisa 5.1.60 → 5.1.64
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 +9 -0
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +10 -0
- package/src/core/config/config-defaults.js +28 -1
- package/src/core/tasks/task-runner.js +17 -2
- package/src/core/tasks/task-store.js +80 -14
- package/src/core/tools/tool-registry.js +20 -5
- package/src/core/tools/weighted-resource-governor.js +153 -0
- package/src/index.js +20 -0
- package/src/official-tools.lock.json +56 -42
- package/src/runtime/create-app.js +1 -1
- package/src/runtime/create-headless-app.js +2 -2
- package/src/runtime/paths.js +4 -0
- package/src/runtime/service-manager.js +3 -1
- package/src/runtime/service-supervisor.js +98 -0
- package/src/transport/telegram/bot.js +77 -9
- package/src/transport/telegram/chat-queue.js +13 -2
- package/src/transport/telegram/reply-topic-routing.js +111 -0
- package/src/transport/telegram/task-dispatcher.js +26 -3
- package/src/transport/telegram/telegram-session-bridge.js +7 -0
- package/src/transport/telegram/workspace-topic-store.js +228 -0
- package/test/chat-queue.test.js +32 -0
- package/test/model-selection.test.js +9 -1
- package/test/official-tool-dependencies.test.js +1 -1
- package/test/paths.test.js +8 -0
- package/test/service-manager.test.js +48 -0
- package/test/task-store.test.js +56 -4
- package/test/telegram-reply-topic-routing.test.js +94 -0
- package/test/telegram-task-dispatcher.test.js +51 -0
- package/test/telegram-workspace-topic-store.test.js +124 -0
- package/test/tool-registry-run.test.js +41 -0
- package/test/weighted-resource-governor.test.js +95 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const replyTopicMarkerPattern = /\s*\[\[ARISA_REPLY_TOPIC:(\d+)\]\]\s*$/u;
|
|
2
|
+
const proposalMarkerPattern = /\s*\[\[ARISA_PROPOSE_TOPIC:([^\]\r\n]{1,80})\]\]\s*$/u;
|
|
3
|
+
const anyRoutingMarkerPattern = /\s*\[\[ARISA_(?:REPLY_TOPIC|PROPOSE_TOPIC):[^\]]*\]\]\s*$/u;
|
|
4
|
+
|
|
5
|
+
function singleLine(value, maxLength) {
|
|
6
|
+
return String(value || "").replace(/\s+/g, " ").trim().slice(0, maxLength);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isGeneralWorkspaceRoute(route) {
|
|
10
|
+
return Boolean(route?.workspace
|
|
11
|
+
&& route.threadId == null
|
|
12
|
+
&& route.topicThreadId === route.generalTopicId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function activeTopics(topics = []) {
|
|
16
|
+
return topics
|
|
17
|
+
.map((topic) => ({
|
|
18
|
+
threadId: Number(topic?.threadId),
|
|
19
|
+
name: singleLine(topic?.name, 80),
|
|
20
|
+
description: singleLine(topic?.description, 240)
|
|
21
|
+
}))
|
|
22
|
+
.filter((topic) => Number.isSafeInteger(topic.threadId) && topic.threadId > 0 && topic.name)
|
|
23
|
+
.sort((left, right) => left.threadId - right.threadId)
|
|
24
|
+
.slice(0, 32);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildGeneralReplyRoutingInstruction(topics = [], recentProposals = []) {
|
|
28
|
+
const available = activeTopics(topics);
|
|
29
|
+
const options = available.length
|
|
30
|
+
? available.map((topic) => [
|
|
31
|
+
`- ${topic.threadId}: ${topic.name}`,
|
|
32
|
+
topic.description ? ` — ${topic.description}` : ""
|
|
33
|
+
].join(""))
|
|
34
|
+
: ["- No named topics are registered yet."];
|
|
35
|
+
const proposals = recentProposals
|
|
36
|
+
.map((proposal) => singleLine(proposal?.name, 80))
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.slice(0, 12);
|
|
39
|
+
return [
|
|
40
|
+
"Telegram General-topic management instruction:",
|
|
41
|
+
"This applies only because the incoming message was written in General inside an owner workspace supergroup. It never applies to a private chat with the bot.",
|
|
42
|
+
"The original message must stay in General and the conversation remains in the General session.",
|
|
43
|
+
"If, and only if, your final response clearly belongs to exactly one registered topic below, append [[ARISA_REPLY_TOPIC:<id>]] as the final line. Do not mention this routing marker.",
|
|
44
|
+
"If the relationship is weak, ambiguous, or no topic fits, do not append a reply marker and the response will remain in General.",
|
|
45
|
+
"You may occasionally propose a new topic only when the General conversation shows a substantial theme recurring across multiple turns and no registered topic fits. Ask for explicit confirmation in the visible response and append [[ARISA_PROPOSE_TOPIC:<short name>]] as the final line. Never create a topic without explicit confirmation.",
|
|
46
|
+
proposals.length ? `Do not repeat these recent topic proposals: ${proposals.join(", ")}.` : null,
|
|
47
|
+
"Registered reply topics:",
|
|
48
|
+
...options
|
|
49
|
+
].filter(Boolean).join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function appendGeneralReplyRoutingInstruction(prompt, topics = [], recentProposals = []) {
|
|
53
|
+
return `${String(prompt || "")}\n\n${buildGeneralReplyRoutingInstruction(topics, recentProposals)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function extractReplyTopicMetadata(text, topics = []) {
|
|
57
|
+
let cleanedText = String(text || "");
|
|
58
|
+
let requestedThreadId = null;
|
|
59
|
+
let proposal = "";
|
|
60
|
+
let changed = true;
|
|
61
|
+
while (changed) {
|
|
62
|
+
changed = false;
|
|
63
|
+
const replyMatch = cleanedText.match(replyTopicMarkerPattern);
|
|
64
|
+
if (replyMatch) {
|
|
65
|
+
requestedThreadId ??= Number(replyMatch[1]);
|
|
66
|
+
cleanedText = cleanedText.replace(replyTopicMarkerPattern, "");
|
|
67
|
+
changed = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const proposalMatch = cleanedText.match(proposalMarkerPattern);
|
|
71
|
+
if (proposalMatch) {
|
|
72
|
+
proposal ||= singleLine(proposalMatch[1], 80);
|
|
73
|
+
cleanedText = cleanedText.replace(proposalMarkerPattern, "");
|
|
74
|
+
changed = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (anyRoutingMarkerPattern.test(cleanedText)) {
|
|
78
|
+
cleanedText = cleanedText.replace(anyRoutingMarkerPattern, "");
|
|
79
|
+
changed = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const available = activeTopics(topics);
|
|
84
|
+
const allowed = available.some((topic) => topic.threadId === requestedThreadId);
|
|
85
|
+
return {
|
|
86
|
+
text: cleanedText.trimEnd(),
|
|
87
|
+
threadId: allowed ? requestedThreadId : null,
|
|
88
|
+
proposal
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function routeGeneralWorkspaceReply({ route, text, topics = [] }) {
|
|
93
|
+
const generalWorkspaceRoute = isGeneralWorkspaceRoute(route);
|
|
94
|
+
const extracted = extractReplyTopicMetadata(text, generalWorkspaceRoute ? topics : []);
|
|
95
|
+
if (!generalWorkspaceRoute) {
|
|
96
|
+
return { text: extracted.text, route, topic: null, proposal: "" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const topic = activeTopics(topics).find((item) => item.threadId === extracted.threadId) || null;
|
|
100
|
+
if (!topic) return { text: extracted.text, route, topic: null, proposal: extracted.proposal };
|
|
101
|
+
return {
|
|
102
|
+
text: extracted.text,
|
|
103
|
+
route: {
|
|
104
|
+
...route,
|
|
105
|
+
threadId: topic.threadId,
|
|
106
|
+
topicThreadId: topic.threadId
|
|
107
|
+
},
|
|
108
|
+
topic,
|
|
109
|
+
proposal: ""
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -5,6 +5,13 @@ function errorMessage(error) {
|
|
|
5
5
|
return error instanceof Error ? error.message : String(error);
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
function boundedTimeout(value, fallback) {
|
|
9
|
+
const parsed = Number(value);
|
|
10
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
11
|
+
? Math.min(Math.round(parsed), 60 * 60_000)
|
|
12
|
+
: fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
function requireChatId(task) {
|
|
9
16
|
const chatId = task.payload?.chatId;
|
|
10
17
|
if (chatId == null || chatId === "") {
|
|
@@ -31,7 +38,7 @@ function failureDestination(task) {
|
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
function buildFailureNotice({ task, result, error }) {
|
|
34
|
-
const uncertain = result?.status === "outcome_uncertain";
|
|
41
|
+
const uncertain = result?.status === "outcome_uncertain" || result?.lastOutcome === "outcome_uncertain";
|
|
35
42
|
const recurring = result?.terminalFailure === true && result?.status === "pending";
|
|
36
43
|
const lines = [
|
|
37
44
|
uncertain ? "⚠️ Arisa task outcome is uncertain" : "⚠️ Arisa task failed",
|
|
@@ -51,8 +58,12 @@ export function createTelegramTaskDispatcher({
|
|
|
51
58
|
toolRegistry,
|
|
52
59
|
resourceNotes,
|
|
53
60
|
agentManager,
|
|
61
|
+
taskTimeouts = {},
|
|
54
62
|
logger
|
|
55
63
|
}) {
|
|
64
|
+
const agentTimeoutMs = boundedTimeout(taskTimeouts.agentTimeoutMs, 15 * 60_000);
|
|
65
|
+
const eventTimeoutMs = boundedTimeout(taskTimeouts.eventTimeoutMs, 5 * 60_000);
|
|
66
|
+
|
|
56
67
|
async function dispatchAgentTask(task, chatId) {
|
|
57
68
|
if (!task.payload.prompt) throw new NonRetryableTaskError("agent_task missing prompt");
|
|
58
69
|
logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
|
|
@@ -60,7 +71,8 @@ export function createTelegramTaskDispatcher({
|
|
|
60
71
|
chatId,
|
|
61
72
|
prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
|
|
62
73
|
label: `scheduled task ${task.id}`,
|
|
63
|
-
route: task.route
|
|
74
|
+
route: task.route,
|
|
75
|
+
timeoutMs: agentTimeoutMs
|
|
64
76
|
});
|
|
65
77
|
}
|
|
66
78
|
|
|
@@ -79,7 +91,8 @@ export function createTelegramTaskDispatcher({
|
|
|
79
91
|
chatId,
|
|
80
92
|
prompt: await buildAsyncEventPrompt(task, resourceNotes),
|
|
81
93
|
label: `agent event ${task.id}`,
|
|
82
|
-
route: task.route
|
|
94
|
+
route: task.route,
|
|
95
|
+
timeoutMs: eventTimeoutMs
|
|
83
96
|
});
|
|
84
97
|
}
|
|
85
98
|
|
|
@@ -124,6 +137,16 @@ export function createTelegramTaskDispatcher({
|
|
|
124
137
|
const runner = createTaskRunner({
|
|
125
138
|
taskStore,
|
|
126
139
|
dispatch: dispatchTask,
|
|
140
|
+
laneKey(task) {
|
|
141
|
+
if (task.kind === "poll_tool") {
|
|
142
|
+
return `poll:${task.payload?.chatId}:${task.payload?.toolName || task.id}`;
|
|
143
|
+
}
|
|
144
|
+
const destination = task.route?.transport === "telegram" ? task.route.destination : null;
|
|
145
|
+
if (!destination?.chatId || Number(destination.threadId) === 1) {
|
|
146
|
+
return `agent:${task.payload?.chatId}`;
|
|
147
|
+
}
|
|
148
|
+
return `agent:${destination.chatId}:${destination.threadId || 0}`;
|
|
149
|
+
},
|
|
127
150
|
onTerminalFailure: notifyTerminalFailure,
|
|
128
151
|
logger
|
|
129
152
|
});
|
|
@@ -24,6 +24,7 @@ export function createTelegramSessionBridgeController({
|
|
|
24
24
|
agentManager,
|
|
25
25
|
artifactStore,
|
|
26
26
|
sessionSeeds,
|
|
27
|
+
workspaceTopics,
|
|
27
28
|
getChatState,
|
|
28
29
|
buildTopicInitializationHandoff,
|
|
29
30
|
logger
|
|
@@ -61,6 +62,12 @@ export function createTelegramSessionBridgeController({
|
|
|
61
62
|
throw new Error("The General topic already uses the owner's private session and cannot be reinitialized here.");
|
|
62
63
|
}
|
|
63
64
|
const handoff = buildTopicInitializationHandoff({ name, context });
|
|
65
|
+
await workspaceTopics.upsertTopic(route.ownerChatId, route.transportChatId, {
|
|
66
|
+
threadId: messageThreadId,
|
|
67
|
+
name,
|
|
68
|
+
description: context,
|
|
69
|
+
source: "arisa-initialized"
|
|
70
|
+
});
|
|
64
71
|
await sessionSeeds.set(initializedSessionId, handoff);
|
|
65
72
|
agentManager.resetSession(initializedSessionId, { handoff });
|
|
66
73
|
await agentManager.waitForSessionClose(initializedSessionId);
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getChatTelegramWorkspacesFile } from "../../runtime/paths.js";
|
|
4
|
+
|
|
5
|
+
const recentProposalWindowMs = 30 * 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
function emptyState() {
|
|
8
|
+
return { version: 1, groups: {} };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function integer(value, label) {
|
|
12
|
+
const parsed = Number(value);
|
|
13
|
+
if (!Number.isSafeInteger(parsed)) throw new Error(`${label} must be an integer`);
|
|
14
|
+
return parsed;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function oneLine(value, maxLength) {
|
|
18
|
+
return String(value || "").replace(/\s+/g, " ").trim().slice(0, maxLength);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeState(parsed) {
|
|
22
|
+
if (!parsed || parsed.version !== 1 || !parsed.groups || typeof parsed.groups !== "object") {
|
|
23
|
+
throw new Error("Unsupported Telegram workspace topic state");
|
|
24
|
+
}
|
|
25
|
+
return parsed;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function readState(file) {
|
|
29
|
+
try {
|
|
30
|
+
return normalizeState(JSON.parse(await readFile(file, "utf8")));
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error?.code === "ENOENT") return emptyState();
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function writeState(file, state) {
|
|
38
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
39
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
40
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
41
|
+
await rename(temporary, file);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function workspace(state, groupChatId) {
|
|
45
|
+
const groupKey = String(groupChatId);
|
|
46
|
+
state.groups[groupKey] ||= { topics: {}, proposals: {} };
|
|
47
|
+
state.groups[groupKey].topics ||= {};
|
|
48
|
+
state.groups[groupKey].proposals ||= {};
|
|
49
|
+
return state.groups[groupKey];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeTopic(threadId, topic) {
|
|
53
|
+
return {
|
|
54
|
+
threadId: Number(threadId),
|
|
55
|
+
name: oneLine(topic?.name, 80),
|
|
56
|
+
description: oneLine(topic?.description, 240),
|
|
57
|
+
status: topic?.status === "closed" ? "closed" : "open",
|
|
58
|
+
source: oneLine(topic?.source, 40),
|
|
59
|
+
createdAt: oneLine(topic?.createdAt, 40),
|
|
60
|
+
updatedAt: oneLine(topic?.updatedAt, 40)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function migrateLegacyReplyTopics(config, store) {
|
|
65
|
+
let migratedGroups = 0;
|
|
66
|
+
for (const [groupChatId, workspaceConfig] of Object.entries(config.telegram?.ownerWorkspaceGroups || {})) {
|
|
67
|
+
const configured = workspaceConfig?.replyTopics;
|
|
68
|
+
if (!configured || typeof configured !== "object" || Array.isArray(configured)) continue;
|
|
69
|
+
const generalTopicId = Number(workspaceConfig.generalTopicId) || 1;
|
|
70
|
+
for (const [threadId, topic] of Object.entries(configured)) {
|
|
71
|
+
const numericThreadId = Number(threadId);
|
|
72
|
+
const name = oneLine(topic?.name, 80);
|
|
73
|
+
if (!Number.isSafeInteger(numericThreadId) || numericThreadId <= 0 || numericThreadId === generalTopicId || !name) continue;
|
|
74
|
+
await store.upsertTopic(workspaceConfig.ownerChatId, Number(groupChatId), {
|
|
75
|
+
threadId: numericThreadId,
|
|
76
|
+
name,
|
|
77
|
+
description: topic?.description,
|
|
78
|
+
source: "legacy-config-migration"
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
delete workspaceConfig.replyTopics;
|
|
82
|
+
migratedGroups += 1;
|
|
83
|
+
}
|
|
84
|
+
return migratedGroups;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class WorkspaceTopicStore {
|
|
88
|
+
constructor({ resolveFile = getChatTelegramWorkspacesFile, now = () => Date.now() } = {}) {
|
|
89
|
+
this.resolveFile = resolveFile;
|
|
90
|
+
this.now = now;
|
|
91
|
+
this.queues = new Map();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async withOwnerLock(ownerChatId, work) {
|
|
95
|
+
const key = String(ownerChatId);
|
|
96
|
+
const previous = this.queues.get(key) || Promise.resolve();
|
|
97
|
+
const current = previous.catch(() => {}).then(work);
|
|
98
|
+
this.queues.set(key, current);
|
|
99
|
+
try {
|
|
100
|
+
return await current;
|
|
101
|
+
} finally {
|
|
102
|
+
if (this.queues.get(key) === current) this.queues.delete(key);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async read(ownerChatId) {
|
|
107
|
+
await (this.queues.get(String(ownerChatId)) || Promise.resolve()).catch(() => {});
|
|
108
|
+
return readState(this.resolveFile(ownerChatId));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async listTopics(ownerChatId, groupChatId, { includeClosed = false } = {}) {
|
|
112
|
+
const state = await this.read(ownerChatId);
|
|
113
|
+
const group = state.groups[String(groupChatId)];
|
|
114
|
+
if (!group?.topics) return [];
|
|
115
|
+
return Object.entries(group.topics)
|
|
116
|
+
.map(([threadId, topic]) => normalizeTopic(threadId, topic))
|
|
117
|
+
.filter((topic) => Number.isSafeInteger(topic.threadId)
|
|
118
|
+
&& topic.threadId > 0
|
|
119
|
+
&& topic.name
|
|
120
|
+
&& (includeClosed || topic.status === "open"))
|
|
121
|
+
.sort((left, right) => left.threadId - right.threadId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async upsertTopic(ownerChatId, groupChatId, { threadId, name, description, status = "open", source = "observed" }) {
|
|
125
|
+
const scopedOwner = integer(ownerChatId, "ownerChatId");
|
|
126
|
+
const scopedGroup = integer(groupChatId, "groupChatId");
|
|
127
|
+
const scopedThread = integer(threadId, "threadId");
|
|
128
|
+
const scopedName = oneLine(name, 80);
|
|
129
|
+
if (scopedThread <= 0) throw new Error("threadId must be positive");
|
|
130
|
+
if (!scopedName) throw new Error("topic name is required");
|
|
131
|
+
|
|
132
|
+
return this.withOwnerLock(scopedOwner, async () => {
|
|
133
|
+
const file = this.resolveFile(scopedOwner);
|
|
134
|
+
const state = await readState(file);
|
|
135
|
+
const group = workspace(state, scopedGroup);
|
|
136
|
+
const key = String(scopedThread);
|
|
137
|
+
const existing = group.topics[key] || {};
|
|
138
|
+
const timestamp = new Date(this.now()).toISOString();
|
|
139
|
+
group.topics[key] = {
|
|
140
|
+
...existing,
|
|
141
|
+
name: scopedName,
|
|
142
|
+
description: description === undefined ? oneLine(existing.description, 240) : oneLine(description, 240),
|
|
143
|
+
status: status === "closed" ? "closed" : "open",
|
|
144
|
+
source: oneLine(source, 40) || oneLine(existing.source, 40) || "observed",
|
|
145
|
+
createdAt: existing.createdAt || timestamp,
|
|
146
|
+
updatedAt: timestamp
|
|
147
|
+
};
|
|
148
|
+
await writeState(file, state);
|
|
149
|
+
return normalizeTopic(key, group.topics[key]);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async setTopicStatus(ownerChatId, groupChatId, threadId, status) {
|
|
154
|
+
const scopedOwner = integer(ownerChatId, "ownerChatId");
|
|
155
|
+
const scopedGroup = integer(groupChatId, "groupChatId");
|
|
156
|
+
const scopedThread = integer(threadId, "threadId");
|
|
157
|
+
return this.withOwnerLock(scopedOwner, async () => {
|
|
158
|
+
const file = this.resolveFile(scopedOwner);
|
|
159
|
+
const state = await readState(file);
|
|
160
|
+
const topic = state.groups[String(scopedGroup)]?.topics?.[String(scopedThread)];
|
|
161
|
+
if (!topic) return null;
|
|
162
|
+
topic.status = status === "closed" ? "closed" : "open";
|
|
163
|
+
topic.updatedAt = new Date(this.now()).toISOString();
|
|
164
|
+
await writeState(file, state);
|
|
165
|
+
return normalizeTopic(scopedThread, topic);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async observeMessage(route, message = {}) {
|
|
170
|
+
if (!route?.workspace || !route.ownerChatId || !route.transportChatId) return null;
|
|
171
|
+
const threadId = route.topicThreadId;
|
|
172
|
+
if (!Number.isSafeInteger(threadId) || threadId === route.generalTopicId) return null;
|
|
173
|
+
if (message.forum_topic_created?.name) {
|
|
174
|
+
return this.upsertTopic(route.ownerChatId, route.transportChatId, {
|
|
175
|
+
threadId,
|
|
176
|
+
name: message.forum_topic_created.name,
|
|
177
|
+
source: "telegram-created"
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (message.forum_topic_edited?.name) {
|
|
181
|
+
return this.upsertTopic(route.ownerChatId, route.transportChatId, {
|
|
182
|
+
threadId,
|
|
183
|
+
name: message.forum_topic_edited.name,
|
|
184
|
+
source: "telegram-edited"
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (message.forum_topic_closed) {
|
|
188
|
+
return this.setTopicStatus(route.ownerChatId, route.transportChatId, threadId, "closed");
|
|
189
|
+
}
|
|
190
|
+
if (message.forum_topic_reopened) {
|
|
191
|
+
return this.setTopicStatus(route.ownerChatId, route.transportChatId, threadId, "open");
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async recordProposal(ownerChatId, groupChatId, name) {
|
|
197
|
+
const scopedOwner = integer(ownerChatId, "ownerChatId");
|
|
198
|
+
const scopedGroup = integer(groupChatId, "groupChatId");
|
|
199
|
+
const scopedName = oneLine(name, 80);
|
|
200
|
+
if (!scopedName) return null;
|
|
201
|
+
return this.withOwnerLock(scopedOwner, async () => {
|
|
202
|
+
const file = this.resolveFile(scopedOwner);
|
|
203
|
+
const state = await readState(file);
|
|
204
|
+
const group = workspace(state, scopedGroup);
|
|
205
|
+
const key = scopedName.toLocaleLowerCase("en-US");
|
|
206
|
+
group.proposals[key] = {
|
|
207
|
+
name: scopedName,
|
|
208
|
+
proposedAt: new Date(this.now()).toISOString()
|
|
209
|
+
};
|
|
210
|
+
const ordered = Object.entries(group.proposals)
|
|
211
|
+
.sort((left, right) => String(right[1]?.proposedAt || "").localeCompare(String(left[1]?.proposedAt || "")))
|
|
212
|
+
.slice(0, 50);
|
|
213
|
+
group.proposals = Object.fromEntries(ordered);
|
|
214
|
+
await writeState(file, state);
|
|
215
|
+
return group.proposals[key];
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async listRecentProposals(ownerChatId, groupChatId) {
|
|
220
|
+
const state = await this.read(ownerChatId);
|
|
221
|
+
const proposals = state.groups[String(groupChatId)]?.proposals || {};
|
|
222
|
+
const cutoff = this.now() - recentProposalWindowMs;
|
|
223
|
+
return Object.values(proposals)
|
|
224
|
+
.filter((proposal) => Date.parse(proposal?.proposedAt || "") >= cutoff)
|
|
225
|
+
.sort((left, right) => String(right.proposedAt).localeCompare(String(left.proposedAt)))
|
|
226
|
+
.slice(0, 12);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
createChatStateStore,
|
|
5
|
+
createPromptExecutionReceipt,
|
|
6
|
+
drainChatPromptQueue,
|
|
7
|
+
queueChatPrompt
|
|
8
|
+
} from "../src/transport/telegram/chat-queue.js";
|
|
9
|
+
|
|
10
|
+
test("execution receipts start only when their queued prompt begins", async () => {
|
|
11
|
+
const state = createChatStateStore().get("chat-1");
|
|
12
|
+
const starts = [];
|
|
13
|
+
const receipt = createPromptExecutionReceipt(() => starts.push("second"));
|
|
14
|
+
queueChatPrompt(state, "second", { receipt });
|
|
15
|
+
let releaseFirst;
|
|
16
|
+
const first = new Promise((resolve) => { releaseFirst = resolve; });
|
|
17
|
+
|
|
18
|
+
const draining = drainChatPromptQueue({
|
|
19
|
+
chatState: state,
|
|
20
|
+
initialPrompt: "first",
|
|
21
|
+
processPrompt: async ({ prompt }) => {
|
|
22
|
+
if (prompt === "first") await first;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
27
|
+
assert.deepEqual(starts, []);
|
|
28
|
+
releaseFirst();
|
|
29
|
+
await draining;
|
|
30
|
+
assert.deepEqual(starts, ["second"]);
|
|
31
|
+
await receipt.promise;
|
|
32
|
+
});
|
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
selectChatSpeed,
|
|
11
11
|
selectChatThinkingLevel
|
|
12
12
|
} from "../src/core/agent/model-selection.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
applyConfigDefaults,
|
|
15
|
+
piConfigDefaults,
|
|
16
|
+
telegramConfigDefaults,
|
|
17
|
+
toolExecutionConfigDefaults
|
|
18
|
+
} from "../src/core/config/config-defaults.js";
|
|
14
19
|
import {
|
|
15
20
|
clampModelThinkingLevel,
|
|
16
21
|
listModelThinkingLevels,
|
|
@@ -295,6 +300,9 @@ test("centralizes Telegram and Pi defaults in config", () => {
|
|
|
295
300
|
|
|
296
301
|
assert.equal(config.telegram.modelPickerPageSize, telegramConfigDefaults.modelPickerPageSize);
|
|
297
302
|
assert.equal(config.telegram.busyMessageMode, "steer");
|
|
303
|
+
assert.equal(config.toolExecution.defaultCapacity, toolExecutionConfigDefaults.defaultCapacity);
|
|
304
|
+
assert.equal(config.toolExecution.maxQueuedPerClass, 100);
|
|
305
|
+
assert.deepEqual(config.toolExecution.capacities, {});
|
|
298
306
|
assert.equal(config.pi.thinkingLevel, piConfigDefaults.thinkingLevel);
|
|
299
307
|
assert.equal(config.pi.speed, piConfigDefaults.speed);
|
|
300
308
|
});
|
|
@@ -7,7 +7,7 @@ async function manifest(name) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
test("official orchestrators declare their hard tool dependencies", async () => {
|
|
10
|
-
assert.deepEqual((await manifest("magnific-mcp")).toolDependencies, { "mcp-client": "^0.
|
|
10
|
+
assert.deepEqual((await manifest("magnific-mcp")).toolDependencies, { "mcp-client": "^0.2.0" });
|
|
11
11
|
assert.deepEqual((await manifest("campaign-draft-runner")).toolDependencies, {
|
|
12
12
|
"pr-campaign": "^0.1.0",
|
|
13
13
|
"gmail-workspace": "^0.1.0"
|
package/test/paths.test.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
createIpcSocketPath,
|
|
11
11
|
getChatArtifactsDir,
|
|
12
12
|
getChatSessionSeedFile,
|
|
13
|
+
getChatTelegramWorkspacesFile,
|
|
13
14
|
getChatToolConfigPath,
|
|
14
15
|
getChatToolUsageFile,
|
|
15
16
|
getChatToolStateDir,
|
|
@@ -39,6 +40,13 @@ test("keeps tool usage scoped below the chat state directory", () => {
|
|
|
39
40
|
);
|
|
40
41
|
});
|
|
41
42
|
|
|
43
|
+
test("keeps Telegram workspace topics scoped below the owner chat state directory", () => {
|
|
44
|
+
assert.equal(
|
|
45
|
+
getChatTelegramWorkspacesFile("chat-1"),
|
|
46
|
+
path.join(chatsDir, "chat-1", "state", "telegram-workspaces.json")
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
42
50
|
test("keeps chat tool state and config paths scoped below the chat directory for normal names", () => {
|
|
43
51
|
assert.equal(
|
|
44
52
|
getChatToolStateDir("chat-1", "strudel-agent"),
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
3
4
|
import { createTelegramRestartHandler, telegramCommands } from "../src/transport/telegram/bot.js";
|
|
4
5
|
import { handoffServiceRestart, restartService, serviceEntryFile, waitForServiceStop } from "../src/runtime/service-manager.js";
|
|
6
|
+
import { createServiceSupervisor } from "../src/runtime/service-supervisor.js";
|
|
5
7
|
|
|
6
8
|
test("registers maintenance as native Telegram commands", () => {
|
|
7
9
|
assert.equal(
|
|
@@ -225,6 +227,52 @@ test("restart does not start a second service when shutdown times out", async ()
|
|
|
225
227
|
assert.equal(started, false);
|
|
226
228
|
});
|
|
227
229
|
|
|
230
|
+
test("accepts restart handoff from a worker owned by the active supervisor", async () => {
|
|
231
|
+
let spawned = false;
|
|
232
|
+
await handoffServiceRestart({}, {
|
|
233
|
+
ensureHome: async () => {},
|
|
234
|
+
getStatus: async () => ({ running: true, pid: 77 }),
|
|
235
|
+
openLog: async () => ({ fd: 17, close: async () => {} }),
|
|
236
|
+
spawnProcess: () => ({ pid: 84, unref() { spawned = true; } }),
|
|
237
|
+
environment: { ARISA_SUPERVISOR_PID: "77" },
|
|
238
|
+
currentPid: 41
|
|
239
|
+
});
|
|
240
|
+
assert.equal(spawned, true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("supervisor restarts an unexpectedly exited worker and forwards shutdown", async () => {
|
|
244
|
+
const children = [];
|
|
245
|
+
const delays = [];
|
|
246
|
+
const spawnProcess = () => {
|
|
247
|
+
const child = new EventEmitter();
|
|
248
|
+
child.pid = 100 + children.length;
|
|
249
|
+
child.kill = (signal) => {
|
|
250
|
+
child.killedWith = signal;
|
|
251
|
+
queueMicrotask(() => child.emit("exit", 0, signal));
|
|
252
|
+
};
|
|
253
|
+
children.push(child);
|
|
254
|
+
return child;
|
|
255
|
+
};
|
|
256
|
+
const supervisor = createServiceSupervisor({
|
|
257
|
+
command: "node",
|
|
258
|
+
args: ["worker.js"],
|
|
259
|
+
restartLimit: 2,
|
|
260
|
+
restartBackoffMs: 5,
|
|
261
|
+
restartBackoffMaxMs: 20,
|
|
262
|
+
stableRuntimeMs: 60_000,
|
|
263
|
+
spawnProcess,
|
|
264
|
+
wait: async (ms) => { delays.push(ms); }
|
|
265
|
+
});
|
|
266
|
+
const running = supervisor.start();
|
|
267
|
+
children[0].emit("exit", 1, null);
|
|
268
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
269
|
+
assert.equal(children.length, 2);
|
|
270
|
+
assert.deepEqual(delays, [5]);
|
|
271
|
+
await supervisor.stop();
|
|
272
|
+
await running;
|
|
273
|
+
assert.equal(children[1].killedWith, "SIGTERM");
|
|
274
|
+
});
|
|
275
|
+
|
|
228
276
|
test("requires explicit positive shutdown timing policy", async () => {
|
|
229
277
|
await assert.rejects(
|
|
230
278
|
waitForServiceStop({ timeoutMs: 0, pollIntervalMs: 1 }),
|
package/test/task-store.test.js
CHANGED
|
@@ -59,12 +59,37 @@ test("claims only due pending tasks and marks them running", async () => {
|
|
|
59
59
|
assert.equal((await store.get("done")).status, "done");
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
test("claims overdue tasks chronologically and records actual execution start", async () => {
|
|
63
|
+
await resetHome();
|
|
64
|
+
const store = new TaskStore();
|
|
65
|
+
const older = new Date(Date.now() - 2_000).toISOString();
|
|
66
|
+
const newer = new Date(Date.now() - 1_000).toISOString();
|
|
67
|
+
await store.add({ id: "newer", kind: "agent_task", runAt: newer });
|
|
68
|
+
await store.add({ id: "older", kind: "agent_task", runAt: older });
|
|
69
|
+
|
|
70
|
+
const claimed = await store.claimDue(2);
|
|
71
|
+
assert.deepEqual(claimed.map((task) => task.id), ["older", "newer"]);
|
|
72
|
+
assert.ok(claimed.every((task) => task.claimedAt));
|
|
73
|
+
assert.equal(claimed[0].executionStartedAt, undefined);
|
|
74
|
+
|
|
75
|
+
const started = await store.markExecutionStarted("older");
|
|
76
|
+
assert.ok(started.executionStartedAt);
|
|
77
|
+
});
|
|
78
|
+
|
|
62
79
|
test("recovers interrupted running tasks for retry after restart", async () => {
|
|
63
80
|
await resetHome();
|
|
64
81
|
const store = new TaskStore();
|
|
65
82
|
const runAt = new Date(Date.now() - 1000).toISOString();
|
|
66
83
|
|
|
67
84
|
await store.add({ id: "interrupted-once", kind: "agent_task", runAt, status: "running" });
|
|
85
|
+
await store.add({
|
|
86
|
+
id: "claimed-not-started",
|
|
87
|
+
kind: "agent_task",
|
|
88
|
+
runAt,
|
|
89
|
+
status: "running",
|
|
90
|
+
attempts: 1,
|
|
91
|
+
claimedAt: new Date().toISOString()
|
|
92
|
+
});
|
|
68
93
|
await store.add({
|
|
69
94
|
id: "interrupted-recurring",
|
|
70
95
|
kind: "poll_tool",
|
|
@@ -77,18 +102,22 @@ test("recovers interrupted running tasks for retry after restart", async () => {
|
|
|
77
102
|
|
|
78
103
|
const recovered = await store.recoverInterrupted();
|
|
79
104
|
|
|
80
|
-
assert.deepEqual(recovered.map((task) => task.id), ["interrupted-once", "interrupted-recurring"]);
|
|
105
|
+
assert.deepEqual(recovered.map((task) => task.id), ["interrupted-once", "claimed-not-started", "interrupted-recurring"]);
|
|
81
106
|
assert.equal(recovered[0].status, "outcome_uncertain");
|
|
82
107
|
assert.equal(recovered[1].status, "pending");
|
|
83
|
-
assert.
|
|
84
|
-
assert.
|
|
108
|
+
assert.equal(recovered[1].attempts, 0);
|
|
109
|
+
assert.equal(recovered[1].lastError, "execution interrupted before start");
|
|
110
|
+
assert.equal(recovered[2].status, "pending");
|
|
111
|
+
assert.ok(Date.parse(recovered[2].runAt) > Date.now());
|
|
112
|
+
assert.equal(recovered[0].lastError, "execution interrupted before confirmation");
|
|
113
|
+
assert.equal(recovered[2].lastError, "execution interrupted before confirmation");
|
|
85
114
|
assert.equal((await store.get("still-pending")).status, "pending");
|
|
86
115
|
assert.equal((await store.get("already-done")).status, "done");
|
|
87
116
|
|
|
88
117
|
const restartedStore = new TaskStore();
|
|
89
118
|
assert.deepEqual(
|
|
90
119
|
(await restartedStore.claimDue()).map((task) => task.id),
|
|
91
|
-
["still-pending"]
|
|
120
|
+
["claimed-not-started", "still-pending"]
|
|
92
121
|
);
|
|
93
122
|
});
|
|
94
123
|
|
|
@@ -270,6 +299,29 @@ test("moves legacy Telegram routing out of task payloads", async () => {
|
|
|
270
299
|
assert.equal(task.payload.telegramContext, undefined);
|
|
271
300
|
});
|
|
272
301
|
|
|
302
|
+
test("keeps recurring tasks scheduled after an uncertain outcome without replaying the occurrence", async () => {
|
|
303
|
+
await resetHome();
|
|
304
|
+
const store = new TaskStore();
|
|
305
|
+
await store.add({
|
|
306
|
+
id: "recurring-uncertain",
|
|
307
|
+
kind: "agent_task",
|
|
308
|
+
status: "running",
|
|
309
|
+
attempts: 1,
|
|
310
|
+
recurrence: { type: "interval", everySeconds: 60 }
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const task = await store.retryOrFail("recurring-uncertain", "deadline exceeded", {
|
|
314
|
+
retryable: false,
|
|
315
|
+
outcomeUncertain: true
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
assert.equal(task.status, "pending");
|
|
319
|
+
assert.equal(task.lastOutcome, "outcome_uncertain");
|
|
320
|
+
assert.equal(task.attempts, 0);
|
|
321
|
+
assert.equal(task.terminalFailure, true);
|
|
322
|
+
assert.ok(Date.parse(task.runAt) > Date.now());
|
|
323
|
+
});
|
|
324
|
+
|
|
273
325
|
test("records uncertain outcomes without retrying", async () => {
|
|
274
326
|
await resetHome();
|
|
275
327
|
const store = new TaskStore();
|