arisa 5.2.20 → 5.2.21
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/LOW-MEMORY.md +13 -1
- package/package.json +1 -1
- package/src/core/agent/agent-turn-coordinator.js +59 -3
- package/src/core/config/config-defaults.js +4 -0
- package/src/core/tasks/task-database.js +128 -0
- package/src/core/tasks/task-store.js +42 -101
- package/src/core/tools/daemon-journal.js +115 -0
- package/src/core/tools/daemon-processes.js +10 -1
- package/src/core/tools/daemon-protocol.js +1 -1
- package/src/core/tools/daemon-worker.js +37 -5
- package/src/index.js +35 -1
- package/src/platform/paths.js +2 -1
- package/src/runtime/worker-recovery-report.js +7 -4
- package/src/transport/telegram/task-dispatcher.js +8 -8
- package/test/agent-turn-coordinator.test.js +48 -0
- package/test/cli-command.test.js +52 -0
- package/test/daemon-runtime.test.js +44 -4
- package/test/pi-compaction.test.js +1 -0
- package/test/task-database.test.js +130 -0
- package/test/task-store.test.js +10 -5
- package/test/telegram-task-dispatcher.test.js +2 -5
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const defaultRetentionMs = 24 * 60 * 60_000;
|
|
5
|
+
const defaultMaxCompleted = 2_048;
|
|
6
|
+
const operationBatchSize = 64;
|
|
7
|
+
|
|
8
|
+
function positiveInteger(value, fallback) {
|
|
9
|
+
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resultId(file) {
|
|
13
|
+
return file.endsWith(".result.json") ? file.slice(0, -".result.json".length) : "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function activeId(file) {
|
|
17
|
+
return file.replace(/\.(?:request|processing)\.json$/, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function entries(directory) {
|
|
21
|
+
try {
|
|
22
|
+
return await readdir(directory, { withFileTypes: true });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error?.code === "ENOENT") return [];
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function inBatches(items, operation) {
|
|
30
|
+
for (let index = 0; index < items.length; index += operationBatchSize) {
|
|
31
|
+
await Promise.all(items.slice(index, index + operationBatchSize).map(operation));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function completedRecords(directory, location, directoryEntries) {
|
|
36
|
+
const records = directoryEntries
|
|
37
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".result.json"))
|
|
38
|
+
.map((entry) => ({
|
|
39
|
+
id: resultId(entry.name),
|
|
40
|
+
name: entry.name,
|
|
41
|
+
file: path.join(directory, entry.name),
|
|
42
|
+
location,
|
|
43
|
+
mtimeMs: 0
|
|
44
|
+
}));
|
|
45
|
+
await inBatches(records, async (record) => {
|
|
46
|
+
record.mtimeMs = (await stat(record.file)).mtimeMs;
|
|
47
|
+
});
|
|
48
|
+
return records;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function moveLegacyResult(record, resultsDir) {
|
|
52
|
+
const destination = path.join(resultsDir, record.name);
|
|
53
|
+
try {
|
|
54
|
+
await rename(record.file, destination);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error?.code !== "EEXIST") throw error;
|
|
57
|
+
await rm(record.file, { force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function ensureDaemonJournal(paths) {
|
|
62
|
+
await Promise.all([
|
|
63
|
+
mkdir(paths.commandsDir, { recursive: true }),
|
|
64
|
+
mkdir(paths.resultsDir, { recursive: true })
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function maintainDaemonJournal(paths, policy = {}, { now = Date.now } = {}) {
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
await ensureDaemonJournal(paths);
|
|
71
|
+
const [commandEntries, resultEntries] = await Promise.all([
|
|
72
|
+
entries(paths.commandsDir),
|
|
73
|
+
entries(paths.resultsDir)
|
|
74
|
+
]);
|
|
75
|
+
const activeIds = new Set(commandEntries
|
|
76
|
+
.filter((entry) => entry.isFile() && /\.(?:request|processing)\.json$/.test(entry.name))
|
|
77
|
+
.map((entry) => activeId(entry.name)));
|
|
78
|
+
const completed = [
|
|
79
|
+
...await completedRecords(paths.resultsDir, "results", resultEntries),
|
|
80
|
+
...await completedRecords(paths.commandsDir, "legacy", commandEntries)
|
|
81
|
+
].sort((left, right) => right.mtimeMs - left.mtimeMs || left.name.localeCompare(right.name));
|
|
82
|
+
const retentionMs = positiveInteger(policy.journalRetentionMs, defaultRetentionMs);
|
|
83
|
+
const maxCompleted = positiveInteger(policy.journalMaxCompleted, defaultMaxCompleted);
|
|
84
|
+
const cutoff = now() - retentionMs;
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const retained = [];
|
|
87
|
+
const removed = [];
|
|
88
|
+
let retainedCompleted = 0;
|
|
89
|
+
|
|
90
|
+
for (const record of completed) {
|
|
91
|
+
const duplicate = seen.has(record.id);
|
|
92
|
+
const protectedByActiveJob = activeIds.has(record.id);
|
|
93
|
+
const withinRetention = record.mtimeMs >= cutoff;
|
|
94
|
+
const withinLimit = retainedCompleted < maxCompleted;
|
|
95
|
+
if (!duplicate && (protectedByActiveJob || (withinRetention && withinLimit))) {
|
|
96
|
+
seen.add(record.id);
|
|
97
|
+
retained.push(record);
|
|
98
|
+
if (!protectedByActiveJob) retainedCompleted += 1;
|
|
99
|
+
} else {
|
|
100
|
+
removed.push(record);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await inBatches(removed, (record) => rm(record.file, { force: true }));
|
|
105
|
+
const legacy = retained.filter((record) => record.location === "legacy");
|
|
106
|
+
await inBatches(legacy, (record) => moveLegacyResult(record, paths.resultsDir));
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
active: activeIds.size,
|
|
110
|
+
completed: retained.length,
|
|
111
|
+
migrated: legacy.length,
|
|
112
|
+
pruned: removed.length,
|
|
113
|
+
scanMs: Math.max(0, Date.now() - startedAt)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -56,6 +56,7 @@ export function daemonPaths(toolNameOrOptions, scope) {
|
|
|
56
56
|
instanceId: getDaemonInstanceId(identity.scope),
|
|
57
57
|
root,
|
|
58
58
|
commandsDir: path.join(root, "commands"),
|
|
59
|
+
resultsDir: path.join(root, "results"),
|
|
59
60
|
pidFile: path.join(root, "daemon.pid"),
|
|
60
61
|
metaFile: path.join(root, "daemon.meta.json"),
|
|
61
62
|
statusFile: path.join(root, "status.json"),
|
|
@@ -186,6 +187,13 @@ export async function readDaemonDiagnostic({ toolName, scope, autoStart = false
|
|
|
186
187
|
nextAt: status.nextRestartAt || null
|
|
187
188
|
},
|
|
188
189
|
disposition: daemonDisposition({ state, alive, autoStart: Boolean(autoStart), restartRequested }),
|
|
190
|
+
journal: status.journal && typeof status.journal === "object" ? {
|
|
191
|
+
active: Number(status.journal.active || 0),
|
|
192
|
+
completed: Number(status.journal.completed || 0),
|
|
193
|
+
migrated: Number(status.journal.migrated || 0),
|
|
194
|
+
pruned: Number(status.journal.pruned || 0),
|
|
195
|
+
scanMs: Number(status.journal.scanMs || 0)
|
|
196
|
+
} : null,
|
|
189
197
|
updatedAt: status.updatedAt || null,
|
|
190
198
|
logFile: paths.logFile
|
|
191
199
|
};
|
|
@@ -275,7 +283,8 @@ export async function unregisterManagedDaemon(toolNameOrOptions, { scope } = {})
|
|
|
275
283
|
rm(paths.startLockFile, { force: true }),
|
|
276
284
|
rm(paths.capabilityFile, { force: true }),
|
|
277
285
|
process.platform === "win32" ? Promise.resolve() : rm(paths.socketFile, { force: true }),
|
|
278
|
-
rm(paths.commandsDir, { recursive: true, force: true })
|
|
286
|
+
rm(paths.commandsDir, { recursive: true, force: true }),
|
|
287
|
+
rm(paths.resultsDir, { recursive: true, force: true })
|
|
279
288
|
]);
|
|
280
289
|
return { toolName: paths.toolName, scope: paths.scope, instanceId: paths.instanceId };
|
|
281
290
|
}
|
|
@@ -10,7 +10,7 @@ export function daemonJobPaths(paths, id) {
|
|
|
10
10
|
return {
|
|
11
11
|
request: path.join(paths.commandsDir, `${id}.request.json`),
|
|
12
12
|
processing: path.join(paths.commandsDir, `${id}.processing.json`),
|
|
13
|
-
result: path.join(paths.commandsDir, `${id}.result.json`)
|
|
13
|
+
result: path.join(paths.resultsDir || paths.commandsDir, `${id}.result.json`)
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
|
-
import { chmod,
|
|
2
|
+
import { chmod, readdir, rename, rm, unlink } from "node:fs/promises";
|
|
3
3
|
import {
|
|
4
4
|
ensureDaemonCapability,
|
|
5
5
|
readJson,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
writeJson
|
|
8
8
|
} from "./daemon-processes.js";
|
|
9
9
|
import { loadDaemonPolicy } from "./daemon-policy.js";
|
|
10
|
+
import { ensureDaemonJournal, maintainDaemonJournal } from "./daemon-journal.js";
|
|
10
11
|
import {
|
|
11
12
|
DAEMON_CONTROL_FIELD,
|
|
12
13
|
DAEMON_PROTOCOL_VERSION,
|
|
@@ -37,7 +38,7 @@ export function createDaemonWorker({ toolName, paths }) {
|
|
|
37
38
|
let statusWrite = Promise.resolve();
|
|
38
39
|
|
|
39
40
|
async function ensure() {
|
|
40
|
-
await
|
|
41
|
+
await ensureDaemonJournal(paths);
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
async function getPid() {
|
|
@@ -96,6 +97,7 @@ export function createDaemonWorker({ toolName, paths }) {
|
|
|
96
97
|
streamBufferBytes: policy.streamBufferBytes || 1_048_576
|
|
97
98
|
};
|
|
98
99
|
const subscribers = new Map();
|
|
100
|
+
const deliveredSequences = new WeakMap();
|
|
99
101
|
const activeJobs = new Map();
|
|
100
102
|
const cancelledJobs = new Set();
|
|
101
103
|
let lastActivity = Date.now();
|
|
@@ -103,21 +105,42 @@ export function createDaemonWorker({ toolName, paths }) {
|
|
|
103
105
|
let exiting = false;
|
|
104
106
|
let acceptingWork = true;
|
|
105
107
|
let processRequested = false;
|
|
108
|
+
let journalMaintenance = Promise.resolve();
|
|
106
109
|
|
|
107
|
-
|
|
110
|
+
function maintainJournal() {
|
|
111
|
+
const operation = journalMaintenance
|
|
112
|
+
.catch(() => {})
|
|
113
|
+
.then(() => maintainDaemonJournal(paths, policy));
|
|
114
|
+
journalMaintenance = operation;
|
|
115
|
+
return operation;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const journal = await maintainJournal();
|
|
108
119
|
const capabilityToken = process.env.ARISA_DAEMON_CAPABILITY || await ensureDaemonCapability(paths);
|
|
109
120
|
await writeStatus({
|
|
110
121
|
state: "starting",
|
|
111
122
|
pid: process.pid,
|
|
112
123
|
heartbeatAt: new Date().toISOString(),
|
|
113
124
|
supportsRecovery: typeof recover === "function",
|
|
125
|
+
journal,
|
|
114
126
|
message: "Daemon work loop started; waiting for health check"
|
|
115
127
|
});
|
|
116
128
|
|
|
129
|
+
async function sendFrame(socket, frame) {
|
|
130
|
+
const delivered = deliveredSequences.get(socket) || new Map();
|
|
131
|
+
if ((delivered.get(frame.jobId) || 0) >= frame.sequence) return true;
|
|
132
|
+
const sent = await writeDaemonSocketFrame(socket, frame, ipcLimits);
|
|
133
|
+
if (sent) {
|
|
134
|
+
delivered.set(frame.jobId, frame.sequence);
|
|
135
|
+
deliveredSequences.set(socket, delivered);
|
|
136
|
+
}
|
|
137
|
+
return sent;
|
|
138
|
+
}
|
|
139
|
+
|
|
117
140
|
async function publish(frame) {
|
|
118
141
|
const sockets = [...(subscribers.get(frame.jobId) || [])];
|
|
119
142
|
for (const socket of sockets) {
|
|
120
|
-
if (!(await
|
|
143
|
+
if (!(await sendFrame(socket, frame))) subscribers.get(frame.jobId)?.delete(socket);
|
|
121
144
|
}
|
|
122
145
|
}
|
|
123
146
|
|
|
@@ -276,7 +299,7 @@ export function createDaemonWorker({ toolName, paths }) {
|
|
|
276
299
|
subscribers.get(jobId).add(socket);
|
|
277
300
|
subscribedJobs.add(jobId);
|
|
278
301
|
readJson(daemonJobPaths(paths, jobId).result, null).then((result) => {
|
|
279
|
-
if (result?.terminal) return
|
|
302
|
+
if (result?.terminal) return sendFrame(socket, result.terminal);
|
|
280
303
|
return processQueue();
|
|
281
304
|
}).catch(() => socket.destroy());
|
|
282
305
|
}
|
|
@@ -293,10 +316,19 @@ export function createDaemonWorker({ toolName, paths }) {
|
|
|
293
316
|
const heartbeatTimer = setInterval(() => {
|
|
294
317
|
writeStatus({ heartbeatAt: new Date().toISOString() }).catch(() => {});
|
|
295
318
|
}, policy.heartbeatIntervalMs);
|
|
319
|
+
const journalTimer = setInterval(() => {
|
|
320
|
+
maintainJournal()
|
|
321
|
+
.then((journal) => writeStatus({ journal }))
|
|
322
|
+
.catch((error) => writeStatus({
|
|
323
|
+
lastError: { at: new Date().toISOString(), phase: "journal", message: error?.message || String(error) }
|
|
324
|
+
}));
|
|
325
|
+
}, policy.journalSweepIntervalMs || 5 * 60_000);
|
|
326
|
+
journalTimer.unref?.();
|
|
296
327
|
const idleTimer = idleTimeoutMs > 0 ? setInterval(async () => {
|
|
297
328
|
if (processing || exiting || Date.now() - lastActivity <= idleTimeoutMs) return;
|
|
298
329
|
exiting = true;
|
|
299
330
|
clearInterval(heartbeatTimer);
|
|
331
|
+
clearInterval(journalTimer);
|
|
300
332
|
clearInterval(idleTimer);
|
|
301
333
|
await beforeExit?.();
|
|
302
334
|
await writeStatus({ state: "stopped", restartRequested: false, nextRestartAt: null, message: "Idle timeout reached" });
|
package/src/index.js
CHANGED
|
@@ -15,7 +15,7 @@ process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
|
|
|
15
15
|
|
|
16
16
|
const args = process.argv.slice(2);
|
|
17
17
|
const cli = parseCliArgs(args);
|
|
18
|
-
const command = cli.positionals[0] || "run";
|
|
18
|
+
const command = cli.flags.help ? "help" : cli.positionals[0] || "run";
|
|
19
19
|
const forceBootstrap = Boolean(cli.flags.bootstrap);
|
|
20
20
|
const verbose = !cli.flags.silent;
|
|
21
21
|
const serviceRunner = Boolean(cli.flags["service-runner"]);
|
|
@@ -226,7 +226,35 @@ async function runForeground() {
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
function printHelp() {
|
|
230
|
+
console.log([
|
|
231
|
+
"Usage: arisa [command] [options]",
|
|
232
|
+
"",
|
|
233
|
+
"Commands:",
|
|
234
|
+
" run Run Arisa in the foreground (default)",
|
|
235
|
+
" tui Open the terminal interface",
|
|
236
|
+
" start Start the background service",
|
|
237
|
+
" stop Stop the background service",
|
|
238
|
+
" restart Restart the background service",
|
|
239
|
+
" status Show background service status",
|
|
240
|
+
" log Show background service logs",
|
|
241
|
+
" flush Remove Arisa state while stopped",
|
|
242
|
+
" slave Manage a Slave host",
|
|
243
|
+
" help Show this help",
|
|
244
|
+
"",
|
|
245
|
+
"Options:",
|
|
246
|
+
" --help Show this help",
|
|
247
|
+
" --silent Reduce runtime logging",
|
|
248
|
+
" --bootstrap Reopen interactive setup"
|
|
249
|
+
].join("\n"));
|
|
250
|
+
}
|
|
251
|
+
|
|
229
252
|
async function main() {
|
|
253
|
+
if (command === "help") {
|
|
254
|
+
printHelp();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
230
258
|
if (slaveCommand) {
|
|
231
259
|
const { runSlaveCli } = await import("./runtime/slave-cli.js");
|
|
232
260
|
const result = await runSlaveCli({
|
|
@@ -334,6 +362,12 @@ async function main() {
|
|
|
334
362
|
return;
|
|
335
363
|
}
|
|
336
364
|
|
|
365
|
+
if (command !== "run") {
|
|
366
|
+
const error = new Error(`Unknown Arisa command: ${command}`);
|
|
367
|
+
error.code = "ARISA_UNKNOWN_COMMAND";
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
|
|
337
371
|
await runForeground();
|
|
338
372
|
}
|
|
339
373
|
|
package/src/platform/paths.js
CHANGED
|
@@ -25,7 +25,8 @@ export function createIpcSocketPath({ homeDir = arisaHomeDir, platform = process
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export const arisaIpcSocketFile = createIpcSocketPath();
|
|
28
|
-
export const tasksFile = path.join(stateDir, "tasks.json");
|
|
28
|
+
export const tasksFile = path.join(stateDir, "tasks.json"); // Legacy migration source; retained unchanged.
|
|
29
|
+
export const tasksDatabaseFile = path.join(stateDir, "tasks.sqlite");
|
|
29
30
|
export const toolsDir = path.join(arisaHomeDir, "tools");
|
|
30
31
|
export const chatsDir = path.join(arisaHomeDir, "chats");
|
|
31
32
|
export const toolStateDir = path.join(stateDir, "tools");
|
|
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { readRecentLogLines } from "./log-viewer.js";
|
|
5
|
-
import { arisaPackageDir, serviceLogFile, stateDir
|
|
5
|
+
import { arisaPackageDir, serviceLogFile, stateDir } from "../platform/paths.js";
|
|
6
6
|
|
|
7
7
|
export const workerRecoveryReportFile = path.join(stateDir, "worker-recovery-report.json");
|
|
8
8
|
|
|
@@ -70,9 +70,12 @@ export function summarizeRecoveryEvidence(lines, report) {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
async function interruptedTaskCount(report, file
|
|
73
|
+
async function interruptedTaskCount(report, file) {
|
|
74
74
|
try {
|
|
75
|
-
|
|
75
|
+
// Explicit JSON input is retained for offline reports. Runtime reads the live DB.
|
|
76
|
+
const document = file
|
|
77
|
+
? JSON.parse(await readFile(file, "utf8"))
|
|
78
|
+
: await new (await import("../core/tasks/task-store.js")).TaskStore().list();
|
|
76
79
|
const tasks = Array.isArray(document) ? document : document.tasks || [];
|
|
77
80
|
const occurredAt = new Date(report.occurredAt).getTime();
|
|
78
81
|
return tasks.filter((task) => {
|
|
@@ -98,7 +101,7 @@ async function runtimeVersion() {
|
|
|
98
101
|
export async function loadWorkerRecoveryReport({
|
|
99
102
|
reportFile = workerRecoveryReportFile,
|
|
100
103
|
logFile = serviceLogFile,
|
|
101
|
-
taskFile
|
|
104
|
+
taskFile,
|
|
102
105
|
readLines = readRecentLogLines,
|
|
103
106
|
getVersion = runtimeVersion
|
|
104
107
|
} = {}) {
|
|
@@ -72,10 +72,11 @@ export function createTelegramTaskDispatcher({
|
|
|
72
72
|
const agentTimeoutMs = boundedTimeout(taskTimeouts.agentTimeoutMs, 15 * 60_000);
|
|
73
73
|
const eventTimeoutMs = boundedTimeout(taskTimeouts.eventTimeoutMs, 5 * 60_000);
|
|
74
74
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
const runHeadlessTool = (toolName, chatId, args) => agentManager.runTool({
|
|
76
|
+
name: toolName,
|
|
77
|
+
request: { args },
|
|
78
|
+
chatId
|
|
79
|
+
});
|
|
79
80
|
|
|
80
81
|
function throwToolFailure(result, toolName, fallbackResolution) {
|
|
81
82
|
const error = new Error(result?.error || `${toolName} failed`);
|
|
@@ -98,11 +99,10 @@ export function createTelegramTaskDispatcher({
|
|
|
98
99
|
async function dispatchAgentTask(task, chatId) {
|
|
99
100
|
if (!task.payload.prompt) throw new NonRetryableTaskError("agent_task missing prompt");
|
|
100
101
|
if (task.authBlock?.toolName) {
|
|
101
|
-
const probe = await
|
|
102
|
+
const probe = await runHeadlessTool(
|
|
102
103
|
task.authBlock.toolName,
|
|
103
104
|
chatId,
|
|
104
|
-
task.authBlock.probeArgs || {}
|
|
105
|
-
`authentication probe ${task.authBlock.toolName}`
|
|
105
|
+
task.authBlock.probeArgs || {}
|
|
106
106
|
);
|
|
107
107
|
if (probe?.ok === false) throwToolFailure(probe, task.authBlock.toolName, task.authBlock);
|
|
108
108
|
logger?.log("tasks", `authentication restored for ${task.authBlock.toolName} (task ${task.id})`);
|
|
@@ -156,7 +156,7 @@ export function createTelegramTaskDispatcher({
|
|
|
156
156
|
if (!toolName) throw new NonRetryableTaskError("poll_tool missing toolName");
|
|
157
157
|
logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
|
|
158
158
|
|
|
159
|
-
const runTool = (args) =>
|
|
159
|
+
const runTool = (args) => runHeadlessTool(toolName, chatId, args);
|
|
160
160
|
|
|
161
161
|
if (task.authBlock) {
|
|
162
162
|
const probe = await runTool(task.authBlock.probeArgs || {});
|
|
@@ -25,6 +25,54 @@ test("interactive turns run before queued background turns without overlapping",
|
|
|
25
25
|
assert.equal(coordinator.diagnostic().completed, 3);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
test("reserves a quiet window for interactive follow-ups before background work", async (t) => {
|
|
29
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
30
|
+
let now = 1_000;
|
|
31
|
+
const coordinator = new AgentTurnCoordinator({
|
|
32
|
+
config: { interactiveQuietMs: 100 },
|
|
33
|
+
now: () => now
|
|
34
|
+
});
|
|
35
|
+
const releaseFirstInteractive = await coordinator.acquire({ priority: "interactive", label: "first" });
|
|
36
|
+
releaseFirstInteractive();
|
|
37
|
+
|
|
38
|
+
let backgroundStarted = false;
|
|
39
|
+
const background = coordinator.acquire({ priority: "background", label: "background" }).then((release) => {
|
|
40
|
+
backgroundStarted = true;
|
|
41
|
+
return release;
|
|
42
|
+
});
|
|
43
|
+
assert.equal(backgroundStarted, false);
|
|
44
|
+
|
|
45
|
+
now = 1_050;
|
|
46
|
+
const releaseFollowUp = await coordinator.acquire({ priority: "interactive", label: "follow-up" });
|
|
47
|
+
assert.equal(coordinator.diagnostic().active.label, "follow-up");
|
|
48
|
+
releaseFollowUp();
|
|
49
|
+
await Promise.resolve();
|
|
50
|
+
assert.equal(backgroundStarted, false);
|
|
51
|
+
|
|
52
|
+
now = 1_150;
|
|
53
|
+
t.mock.timers.tick(100);
|
|
54
|
+
const releaseBackground = await background;
|
|
55
|
+
assert.equal(backgroundStarted, true);
|
|
56
|
+
releaseBackground();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reports wait metrics separately for interactive and background turns", async () => {
|
|
60
|
+
let now = 1_000;
|
|
61
|
+
const coordinator = new AgentTurnCoordinator({ now: () => now });
|
|
62
|
+
const releaseActive = await coordinator.acquire({ priority: "background" });
|
|
63
|
+
const interactive = coordinator.acquire({ priority: "interactive" });
|
|
64
|
+
now = 1_025;
|
|
65
|
+
releaseActive();
|
|
66
|
+
const releaseInteractive = await interactive;
|
|
67
|
+
releaseInteractive();
|
|
68
|
+
|
|
69
|
+
const diagnostic = coordinator.diagnostic();
|
|
70
|
+
assert.equal(diagnostic.priorities.background.completed, 1);
|
|
71
|
+
assert.equal(diagnostic.priorities.interactive.completed, 1);
|
|
72
|
+
assert.equal(diagnostic.priorities.interactive.maxWaitMs, 25);
|
|
73
|
+
assert.equal(diagnostic.priorities.interactive.averageWaitMs, 25);
|
|
74
|
+
});
|
|
75
|
+
|
|
28
76
|
test("background turns expire safely before execution when their queue TTL elapses", async (t) => {
|
|
29
77
|
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
30
78
|
const coordinator = new AgentTurnCoordinator();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const entry = path.join(packageDir, "src", "index.js");
|
|
13
|
+
|
|
14
|
+
async function isolatedEnvironment() {
|
|
15
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "arisa-cli-command-"));
|
|
16
|
+
return {
|
|
17
|
+
home,
|
|
18
|
+
env: { ...process.env, ARISA_HOME: home }
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("prints CLI help without starting the runtime", async (t) => {
|
|
23
|
+
const isolated = await isolatedEnvironment();
|
|
24
|
+
t.after(() => rm(isolated.home, { recursive: true, force: true }));
|
|
25
|
+
|
|
26
|
+
const { stdout, stderr } = await execFileAsync(process.execPath, [entry, "--help"], {
|
|
27
|
+
cwd: packageDir,
|
|
28
|
+
env: isolated.env
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
assert.match(stdout, /^Usage: arisa/m);
|
|
32
|
+
assert.match(stdout, /status\s+Show background service status/);
|
|
33
|
+
assert.equal(stderr, "");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("rejects unknown CLI commands instead of starting the runtime", async (t) => {
|
|
37
|
+
const isolated = await isolatedEnvironment();
|
|
38
|
+
t.after(() => rm(isolated.home, { recursive: true, force: true }));
|
|
39
|
+
|
|
40
|
+
await assert.rejects(
|
|
41
|
+
() => execFileAsync(process.execPath, [entry, "doctor"], {
|
|
42
|
+
cwd: packageDir,
|
|
43
|
+
env: isolated.env
|
|
44
|
+
}),
|
|
45
|
+
(error) => {
|
|
46
|
+
assert.equal(error.code, 1);
|
|
47
|
+
assert.match(error.stderr, /Unknown Arisa command: doctor/);
|
|
48
|
+
assert.doesNotMatch(error.stderr, /loading config|validating Pi session/);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
});
|
|
@@ -21,7 +21,10 @@ const policy = {
|
|
|
21
21
|
restartBackoffMaxMs: 40,
|
|
22
22
|
startupTimeoutMs: 2_000,
|
|
23
23
|
stopTimeoutMs: 300,
|
|
24
|
-
queuePollIntervalMs: 10
|
|
24
|
+
queuePollIntervalMs: 10,
|
|
25
|
+
journalRetentionMs: 24 * 60 * 60_000,
|
|
26
|
+
journalMaxCompleted: 128,
|
|
27
|
+
journalSweepIntervalMs: 60_000
|
|
25
28
|
};
|
|
26
29
|
|
|
27
30
|
await mkdir(path.join(homeDir, "state"), { recursive: true });
|
|
@@ -51,8 +54,10 @@ const {
|
|
|
51
54
|
const { submitDaemonControl: directSubmitDaemonControl } = await import("../src/core/tools/daemon-client.js");
|
|
52
55
|
const {
|
|
53
56
|
DAEMON_EVENT_TYPES: directDaemonEventTypes,
|
|
54
|
-
DAEMON_PROTOCOL_VERSION: directDaemonProtocolVersion
|
|
57
|
+
DAEMON_PROTOCOL_VERSION: directDaemonProtocolVersion,
|
|
58
|
+
daemonJobPaths
|
|
55
59
|
} = await import("../src/core/tools/daemon-protocol.js");
|
|
60
|
+
const { maintainDaemonJournal } = await import("../src/core/tools/daemon-journal.js");
|
|
56
61
|
const { createToolProcessSupervisor, formatDaemonOutcome } = await import("../src/runtime/tool-process-supervisor.js");
|
|
57
62
|
const { superviseDaemon } = await import("../src/core/tools/daemon-health.js");
|
|
58
63
|
const { ToolRegistry } = await import("../src/core/tools/tool-registry.js");
|
|
@@ -127,7 +132,7 @@ test("streams ordered daemon events and persists the terminal result", async ()
|
|
|
127
132
|
assert.deepEqual(output, { echo: "done" });
|
|
128
133
|
assert.deepEqual(events.map((event) => event.type), ["accepted", "progress", "chunk", "completed"]);
|
|
129
134
|
assert.deepEqual(events.map((event) => event.sequence), [1, 2, 3, 4]);
|
|
130
|
-
assert.ok((await readdir(runtime.paths.
|
|
135
|
+
assert.ok((await readdir(runtime.paths.resultsDir)).some((file) => file.endsWith(".result.json")));
|
|
131
136
|
await runtime.stop();
|
|
132
137
|
});
|
|
133
138
|
|
|
@@ -140,7 +145,7 @@ test("cancels a timed-out job without restarting the shared daemon", async () =>
|
|
|
140
145
|
);
|
|
141
146
|
|
|
142
147
|
const terminal = await waitFor(async () => {
|
|
143
|
-
const result = await readJson(
|
|
148
|
+
const result = await readJson(daemonJobPaths(runtime.paths, jobId).result, null);
|
|
144
149
|
return result?.terminal || null;
|
|
145
150
|
});
|
|
146
151
|
assert.equal(terminal.type, "failed");
|
|
@@ -192,6 +197,41 @@ test("recovers queued and accepted journal records after daemon start", async ()
|
|
|
192
197
|
await runtime.stop();
|
|
193
198
|
});
|
|
194
199
|
|
|
200
|
+
test("migrates and bounds completed daemon journal records outside the active queue", async () => {
|
|
201
|
+
const paths = daemonPaths({ toolName: "journal-test", scope: { type: "chat", chatId: "303" } });
|
|
202
|
+
await maintainDaemonJournal(paths);
|
|
203
|
+
for (let index = 0; index < 5; index += 1) {
|
|
204
|
+
await writeJson(path.join(paths.commandsDir, `legacy-${index}.result.json`), {
|
|
205
|
+
id: `legacy-${index}`,
|
|
206
|
+
terminal: { version: 1, jobId: `legacy-${index}`, type: "completed", sequence: 2, payload: { output: { index } } }
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
await writeJson(path.join(paths.commandsDir, "legacy-active.request.json"), {
|
|
210
|
+
id: "legacy-active",
|
|
211
|
+
status: "queued",
|
|
212
|
+
payload: { value: "active" }
|
|
213
|
+
});
|
|
214
|
+
await writeJson(path.join(paths.commandsDir, "legacy-active.result.json"), {
|
|
215
|
+
id: "legacy-active",
|
|
216
|
+
terminal: { version: 1, jobId: "legacy-active", type: "completed", sequence: 2, payload: { output: { active: true } } }
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const journal = await maintainDaemonJournal(paths, {
|
|
220
|
+
journalRetentionMs: 60_000,
|
|
221
|
+
journalMaxCompleted: 2
|
|
222
|
+
});
|
|
223
|
+
const activeFiles = await readdir(paths.commandsDir);
|
|
224
|
+
const resultFiles = await readdir(paths.resultsDir);
|
|
225
|
+
|
|
226
|
+
assert.equal(journal.active, 1);
|
|
227
|
+
assert.equal(journal.completed, 3);
|
|
228
|
+
assert.equal(journal.migrated, 3);
|
|
229
|
+
assert.equal(journal.pruned, 3);
|
|
230
|
+
assert.deepEqual(activeFiles, ["legacy-active.request.json"]);
|
|
231
|
+
assert.equal(resultFiles.includes("legacy-active.result.json"), true);
|
|
232
|
+
assert.equal(resultFiles.length, 3);
|
|
233
|
+
});
|
|
234
|
+
|
|
195
235
|
test("isolates daemon process files and context by chat scope", async () => {
|
|
196
236
|
const first = runtimeFor({ type: "chat", chatId: "101" });
|
|
197
237
|
const second = runtimeFor({ type: "chat", chatId: "202" });
|