arisa 5.2.19 → 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 +49 -0
- package/README.md +2 -1
- package/package.json +7 -8
- package/src/core/agent/agent-turn-coordinator.js +59 -3
- package/src/core/agent/model-selection.js +4 -3
- package/src/core/agent/model-speed.js +11 -3
- package/src/core/artifacts/artifact-index.js +107 -0
- package/src/core/artifacts/artifact-store.js +15 -84
- package/src/core/artifacts/legacy-artifact-reader.js +46 -0
- 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 +45 -5
- package/src/platform/paths.js +7 -1
- package/src/runtime/worker-recovery-report.js +7 -4
- package/src/transport/telegram/model-callback.js +3 -2
- package/src/transport/telegram/model-controls.js +3 -3
- package/src/transport/telegram/model-picker.js +1 -1
- package/src/transport/telegram/task-dispatcher.js +8 -8
- package/test/agent-turn-coordinator.test.js +48 -0
- package/test/artifact-index-memory.test.js +46 -0
- package/test/artifact-index-migration.test.js +88 -0
- package/test/artifact-store.test.js +3 -3
- package/test/cli-command.test.js +52 -0
- package/test/cli-memory.test.js +22 -0
- package/test/daemon-runtime.test.js +44 -4
- package/test/model-selection.test.js +11 -2
- package/test/paths.test.js +2 -0
- package/test/pi-compaction.test.js +1 -0
- package/test/pi-speed-integration.test.js +9 -8
- 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
|
@@ -197,7 +197,7 @@ test("builds and parses the speed picker", () => {
|
|
|
197
197
|
assert.match(picker.replyMarkup.inline_keyboard[1][0].text, /^✓ 1\.5x$/);
|
|
198
198
|
assert.deepEqual(parseSpeedPickerAction("speed:1.5"), { type: "speed", speed: 1.5 });
|
|
199
199
|
assert.deepEqual(parseSpeedPickerAction("speed:1"), { type: "speed", speed: 1 });
|
|
200
|
-
assert.equal(parseSpeedPickerAction("speed:
|
|
200
|
+
assert.equal(parseSpeedPickerAction("speed:3"), null);
|
|
201
201
|
});
|
|
202
202
|
|
|
203
203
|
test("closes the picker after selecting the already active model and effort", async () => {
|
|
@@ -350,7 +350,16 @@ test("maps supported model speeds to provider service tiers", () => {
|
|
|
350
350
|
assert.equal(clampModelSpeed({ ...fastModel, id: "gpt-5.3" }, 1.5), 1);
|
|
351
351
|
assert.equal(speedToServiceTier(1), "default");
|
|
352
352
|
assert.equal(speedToServiceTier(1.5), "priority");
|
|
353
|
-
assert.
|
|
353
|
+
assert.equal(normalizeModelSpeed(2), 2);
|
|
354
|
+
assert.equal(speedToServiceTier(2), "priority");
|
|
355
|
+
const legacyConfig = { pi: { provider: "openai-codex", model: "gpt-6-astra", speed: 1.5 } };
|
|
356
|
+
assert.equal(resolveChatSpeed(legacyConfig, "legacy"), 2);
|
|
357
|
+
assert.equal(legacyConfig.pi.speed, 1.5);
|
|
358
|
+
assert.equal(clampModelSpeed({ ...fastModel, id: "gpt-6-astra" }, 1.5), 2);
|
|
359
|
+
assert.equal(clampModelSpeed({ ...fastModel, id: "gpt-6-astra" }, 2), 2);
|
|
360
|
+
assert.equal(clampModelSpeed(fastModel, 2), 1.5);
|
|
361
|
+
assert.deepEqual(parseSpeedPickerAction("speed:2"), { type: "speed", speed: 2 });
|
|
362
|
+
assert.throws(() => normalizeModelSpeed(3), /Invalid model speed/);
|
|
354
363
|
});
|
|
355
364
|
|
|
356
365
|
test("applies Pi speed to every provider request and updates it in place", async () => {
|
package/test/paths.test.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
chatsDir,
|
|
10
10
|
createIpcSocketPath,
|
|
11
11
|
getChatArtifactsDir,
|
|
12
|
+
getChatArtifactsDatabaseFile,
|
|
12
13
|
getChatSessionSeedFile,
|
|
13
14
|
getChatTelegramWorkspacesFile,
|
|
14
15
|
getChatToolConfigPath,
|
|
@@ -31,6 +32,7 @@ test("keeps chat artifact paths scoped below the chat directory", () => {
|
|
|
31
32
|
const artifactsDir = getChatArtifactsDir("chat-1");
|
|
32
33
|
|
|
33
34
|
assert.equal(artifactsDir, path.join(chatsDir, "chat-1", "artifacts"));
|
|
35
|
+
assert.equal(getChatArtifactsDatabaseFile("chat-1"), path.join(chatsDir, "chat-1", "state", "artifacts.sqlite"));
|
|
34
36
|
});
|
|
35
37
|
|
|
36
38
|
test("keeps pending session seeds scoped below the chat state directory", () => {
|
|
@@ -45,7 +45,7 @@ test("speed picker updates Astra in place, persists per topic, and closes unchan
|
|
|
45
45
|
answerCallbackQuery: async (answer) => answers.push(answer)
|
|
46
46
|
};
|
|
47
47
|
await controls.showSpeedPicker(ctx);
|
|
48
|
-
assert.equal(replies[0][1]?.reply_markup.inline_keyboard[1][0].callback_data, "speed:
|
|
48
|
+
assert.equal(replies[0][1]?.reply_markup.inline_keyboard[1][0].callback_data, "speed:2");
|
|
49
49
|
const handler = createTelegramModelCallbackHandler({
|
|
50
50
|
...controls, config,
|
|
51
51
|
authorizeContext: async () => ({ ok: true }),
|
|
@@ -54,13 +54,14 @@ test("speed picker updates Astra in place, persists per topic, and closes unchan
|
|
|
54
54
|
});
|
|
55
55
|
ctx.callbackQuery = { data: "speed:1.5", message: { message_id: 456 } };
|
|
56
56
|
await handler(ctx);
|
|
57
|
-
assert.deepEqual(updates, [["123:topic:7",
|
|
58
|
-
assert.equal(resolveChatSpeed(writes[0], "123:topic:7"),
|
|
57
|
+
assert.deepEqual(updates, [["123:topic:7", 2]]);
|
|
58
|
+
assert.equal(resolveChatSpeed(writes[0], "123:topic:7"), 2);
|
|
59
59
|
assert.equal(resolveChatSpeed(config, "123:topic:8"), 1);
|
|
60
60
|
assert.equal(resolveChatModelSelection(config, "123:topic:7").sessionRevision, 0);
|
|
61
|
+
ctx.callbackQuery.data = "speed:2";
|
|
61
62
|
await handler(ctx);
|
|
62
63
|
assert.equal(writes.length, 1);
|
|
63
|
-
assert.match(replies.at(-1)[2], /Already using speed
|
|
64
|
+
assert.match(replies.at(-1)[2], /Already using speed 2.0x/);
|
|
64
65
|
ctx.callbackQuery.data = "speed:1";
|
|
65
66
|
await handler(ctx);
|
|
66
67
|
assert.equal(resolveChatSpeed(config, "123:topic:7"), 1);
|
|
@@ -99,15 +100,15 @@ test("Pi SDK sends the selected speed in the actual Codex payload across turns",
|
|
|
99
100
|
...options, transport: "sse", maxRetries: 0
|
|
100
101
|
}), 1);
|
|
101
102
|
session.agent.streamFunction = controller.streamFn;
|
|
102
|
-
for (const speed of [1, 1.5, 1]) {
|
|
103
|
+
for (const speed of [1, 1.5, 2, 1]) {
|
|
103
104
|
controller.setSpeed(speed);
|
|
104
105
|
await session.prompt("Reply OK");
|
|
105
106
|
const message = session.messages.at(-1);
|
|
106
107
|
assert.equal(message.stopReason, "stop", message.errorMessage);
|
|
107
108
|
assert.equal(requests.at(-1).model, model.id);
|
|
108
|
-
assert.equal(requests.at(-1).service_tier, speed
|
|
109
|
+
assert.equal(requests.at(-1).service_tier, speed > 1 ? "priority" : "default");
|
|
109
110
|
}
|
|
110
|
-
assert.equal(requests.length,
|
|
111
|
+
assert.equal(requests.length, 4);
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
test("speed control leaves unsupported provider payloads and hooks untouched", async () => {
|
|
@@ -148,7 +149,7 @@ test("Arisa creates and reuses Telegram sessions and opens its TUI with the inst
|
|
|
148
149
|
try {
|
|
149
150
|
assert.equal(context.session.model.id, "gpt-6-astra");
|
|
150
151
|
assert.equal(context.session.agent.streamFunction, context.speedController.streamFn);
|
|
151
|
-
assert.equal(context.speedController.speed,
|
|
152
|
+
assert.equal(context.speedController.speed, 2);
|
|
152
153
|
await manager.setModelSpeed("123", 1);
|
|
153
154
|
selectChatSpeed(config, "123", 1);
|
|
154
155
|
const reused = await manager.getSessionContext("123", {});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { TaskStore } from "../src/core/tasks/task-store.js";
|
|
10
|
+
|
|
11
|
+
const execute = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
async function fixture(t) {
|
|
14
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "arisa-task-db-"));
|
|
15
|
+
t.after(() => rm(dir, { recursive: true, force: true }));
|
|
16
|
+
const storage = { databaseFile: path.join(dir, "tasks.sqlite"), legacyFile: path.join(dir, "tasks.json") };
|
|
17
|
+
return { storage, store: new TaskStore(storage) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function causes(error) {
|
|
21
|
+
return `${error.message} ${error.cause ? causes(error.cause) : ""}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("task migration is atomic, retains the legacy source and does not reimport it", async (t) => {
|
|
25
|
+
const { storage, store } = await fixture(t);
|
|
26
|
+
const text = JSON.stringify([
|
|
27
|
+
{ id: "old", status: "blocked_auth", kind: "agent_task", payload: { chatId: "owner", prompt: "private", telegramContext: { transportChatId: -100, messageThreadId: 87 } }, authBlock: { retryAfterSeconds: 3600 } }
|
|
28
|
+
]);
|
|
29
|
+
await writeFile(storage.legacyFile, text);
|
|
30
|
+
await store.init();
|
|
31
|
+
assert.equal(await readFile(storage.legacyFile, "utf8"), text);
|
|
32
|
+
const task = await store.get("old");
|
|
33
|
+
assert.equal(task.payload.prompt, "private");
|
|
34
|
+
assert.equal(task.route.destination.threadId, 87);
|
|
35
|
+
assert.equal(task.authBlock.retryAfterSeconds, 3600);
|
|
36
|
+
if (process.platform !== "win32") assert.equal((await stat(storage.databaseFile)).mode & 0o777, 0o600);
|
|
37
|
+
await store.cancel("old");
|
|
38
|
+
await writeFile(storage.legacyFile, "broken obsolete source");
|
|
39
|
+
assert.deepEqual(await new TaskStore(storage).list(), []);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("bad legacy task storage fails explicitly and rolls back partial migration", async (t) => {
|
|
43
|
+
const { storage, store } = await fixture(t);
|
|
44
|
+
for (const text of ["{broken", "{}", '[{"id":"one","status":"pending"},{"id":"one","status":"pending"}]', '[{"id":"one","status":"pending"},{}]']) {
|
|
45
|
+
await writeFile(storage.legacyFile, text);
|
|
46
|
+
await assert.rejects(store.init(), /Task storage operation failed/);
|
|
47
|
+
assert.equal(await readFile(storage.legacyFile, "utf8"), text);
|
|
48
|
+
const db = new DatabaseSync(storage.databaseFile);
|
|
49
|
+
try {
|
|
50
|
+
assert.equal(db.prepare("PRAGMA user_version").get().user_version, 0);
|
|
51
|
+
assert.equal(db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name = 'tasks'").get().n, 0);
|
|
52
|
+
} finally { db.close(); }
|
|
53
|
+
}
|
|
54
|
+
await writeFile(storage.legacyFile, "[]");
|
|
55
|
+
await store.add({ id: "fixed", kind: "agent_task" });
|
|
56
|
+
assert.equal((await store.list()).length, 1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("SQLite corruption and unsupported schemas do not silently fall back to JSON", async (t) => {
|
|
60
|
+
const { storage, store } = await fixture(t);
|
|
61
|
+
await writeFile(storage.databaseFile, "not a sqlite database");
|
|
62
|
+
await assert.rejects(store.list(), /Task storage operation failed/);
|
|
63
|
+
await rm(storage.databaseFile);
|
|
64
|
+
await store.init();
|
|
65
|
+
const db = new DatabaseSync(storage.databaseFile);
|
|
66
|
+
db.exec("PRAGMA user_version = 999");
|
|
67
|
+
db.close();
|
|
68
|
+
await assert.rejects(store.list(), (error) => /Unsupported task database version/.test(causes(error)));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("task writes roll back together and unrelated history is not parsed on the claim path", async (t) => {
|
|
72
|
+
const { storage, store } = await fixture(t);
|
|
73
|
+
await store.add({ id: "history", kind: "agent_task", status: "done" });
|
|
74
|
+
await assert.rejects(store.addMany([{ id: "duplicate" }, { id: "duplicate" }]));
|
|
75
|
+
assert.equal(await store.get("duplicate"), null);
|
|
76
|
+
await store.add({ id: "active", kind: "agent_task", runAt: new Date(0).toISOString() });
|
|
77
|
+
const db = new DatabaseSync(storage.databaseFile);
|
|
78
|
+
db.prepare("UPDATE tasks SET data = ? WHERE id = ?").run("invalid JSON deliberately outside the hot path", "history");
|
|
79
|
+
const plan = db.prepare("EXPLAIN QUERY PLAN SELECT data FROM tasks INDEXED BY tasks_due WHERE status IN ('pending','blocked_auth') AND run_at <= ? ORDER BY run_at, created_at, id LIMIT ?").all(Date.now(), 10);
|
|
80
|
+
assert.ok(plan.some((row) => row.detail.includes("tasks_due")));
|
|
81
|
+
db.close();
|
|
82
|
+
assert.deepEqual((await store.claimDue()).map((task) => task.id), ["active"]);
|
|
83
|
+
assert.equal((await store.complete("active")).status, "done");
|
|
84
|
+
await assert.rejects(store.get("history"), /Task storage operation failed/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("idle task polling stays within a 32 MiB heap with a large terminal history and does not rewrite it", async (t) => {
|
|
88
|
+
const { storage, store } = await fixture(t);
|
|
89
|
+
await store.init();
|
|
90
|
+
const db = new DatabaseSync(storage.databaseFile);
|
|
91
|
+
try {
|
|
92
|
+
db.exec("BEGIN");
|
|
93
|
+
const insert = db.prepare("INSERT INTO tasks (id, status, chat_id, created_at, data) VALUES (?, 'done', 'owner', 0, ?)");
|
|
94
|
+
for (let i = 0; i < 6000; i++) {
|
|
95
|
+
insert.run(`history-${i}`, JSON.stringify({ id: `history-${i}`, status: "done", error: "x".repeat(10000) }));
|
|
96
|
+
}
|
|
97
|
+
db.exec("COMMIT");
|
|
98
|
+
} finally { db.close(); }
|
|
99
|
+
const before = await stat(storage.databaseFile);
|
|
100
|
+
const moduleUrl = new URL("../src/core/tasks/task-store.js", import.meta.url).href;
|
|
101
|
+
const { stdout } = await execute(process.execPath, ["--max-old-space-size=32", "--input-type=module", "-e", `
|
|
102
|
+
import assert from 'node:assert/strict';
|
|
103
|
+
import { TaskStore } from ${JSON.stringify(moduleUrl)};
|
|
104
|
+
const store = new TaskStore(${JSON.stringify(storage)});
|
|
105
|
+
for (let i = 0; i < 200; i++) assert.deepEqual(await store.claimDue(), []);
|
|
106
|
+
console.log('ok');
|
|
107
|
+
`], { timeout: 30000 });
|
|
108
|
+
assert.equal(stdout.trim(), "ok");
|
|
109
|
+
const after = await stat(storage.databaseFile);
|
|
110
|
+
assert.equal(after.size, before.size);
|
|
111
|
+
assert.equal(after.mtimeMs, before.mtimeMs);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("separate processes cannot claim the same task or overwrite concurrent additions", async (t) => {
|
|
115
|
+
const { storage, store } = await fixture(t);
|
|
116
|
+
await store.addMany(Array.from({ length: 24 }, (_, i) => ({ id: `due-${i}`, kind: "agent_task", runAt: new Date(0).toISOString() })));
|
|
117
|
+
const moduleUrl = new URL("../src/core/tasks/task-store.js", import.meta.url).href;
|
|
118
|
+
const workers = await Promise.all(Array.from({ length: 4 }, (_, i) => execute(process.execPath, ["--input-type=module", "-e", `
|
|
119
|
+
import { TaskStore } from ${JSON.stringify(moduleUrl)};
|
|
120
|
+
const store = new TaskStore(${JSON.stringify(storage)});
|
|
121
|
+
const claimed = await store.claimDue(8);
|
|
122
|
+
await store.add({ id: 'worker-${i}', kind: 'agent_task', runAt: new Date(Date.now()+60000).toISOString() });
|
|
123
|
+
console.log(JSON.stringify(claimed.map(t => t.id)));
|
|
124
|
+
`], { timeout: 15000 })));
|
|
125
|
+
const claimed = workers.flatMap(({ stdout }) => JSON.parse(stdout));
|
|
126
|
+
assert.equal(claimed.length, 24);
|
|
127
|
+
assert.equal(new Set(claimed).size, 24);
|
|
128
|
+
assert.equal((await store.list()).length, 28);
|
|
129
|
+
assert.equal((await store.list({ status: "running" })).length, 24);
|
|
130
|
+
});
|
package/test/task-store.test.js
CHANGED
|
@@ -144,8 +144,10 @@ test("persists auth blocks, claims only due probes, and clears the block after s
|
|
|
144
144
|
assert.equal(blocked.authBlock.toolName, "checker");
|
|
145
145
|
assert.deepEqual(await store.claimDue(), []);
|
|
146
146
|
|
|
147
|
-
store.
|
|
148
|
-
|
|
147
|
+
await store.mutate((tasks) => {
|
|
148
|
+
tasks[0].runAt = new Date(Date.now() - 1000).toISOString();
|
|
149
|
+
return { result: null };
|
|
150
|
+
}, { id: "auth-poll" });
|
|
149
151
|
const [probe] = await store.claimDue();
|
|
150
152
|
assert.equal(probe.status, "running");
|
|
151
153
|
assert.ok(probe.authBlock);
|
|
@@ -248,7 +250,8 @@ test("startup recovery compacts historical terminal tasks without changing activ
|
|
|
248
250
|
|
|
249
251
|
const store = new TaskStore();
|
|
250
252
|
assert.deepEqual(await store.recoverInterrupted(), []);
|
|
251
|
-
const persisted =
|
|
253
|
+
const persisted = await new TaskStore().list();
|
|
254
|
+
assert.equal(JSON.parse(await readFile(tasksFile, "utf8"))[0].payload.prompt, "obsolete prompt");
|
|
252
255
|
const historical = persisted.find((task) => task.id === "historical-done");
|
|
253
256
|
const active = persisted.find((task) => task.id === "active-pending");
|
|
254
257
|
|
|
@@ -278,8 +281,10 @@ test("backs off known failures and fails after the attempt limit", async () => {
|
|
|
278
281
|
assert.ok(Date.parse(retrying.runAt) > Date.now());
|
|
279
282
|
|
|
280
283
|
retrying.runAt = runAt;
|
|
281
|
-
store.
|
|
282
|
-
|
|
284
|
+
await store.mutate((tasks) => {
|
|
285
|
+
tasks[0].runAt = runAt;
|
|
286
|
+
return { result: null };
|
|
287
|
+
}, { id: "retrying" });
|
|
283
288
|
await store.claimDue();
|
|
284
289
|
const failed = await store.retryOrFail("retrying", "still broken");
|
|
285
290
|
assert.equal(failed.status, "failed");
|
|
@@ -110,7 +110,6 @@ test("runs poll tools headlessly and confirms their result", async () => {
|
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
assert.deepEqual(calls, [
|
|
113
|
-
["runTurn", { priority: "background", label: "poll tool checker" }],
|
|
114
113
|
["runTool", { name: "checker", request: { args: { cursor: "4" } }, chatId: 123 }],
|
|
115
114
|
["complete", "poll-1"]
|
|
116
115
|
]);
|
|
@@ -194,8 +193,7 @@ test("probes a blocked agent task before starting another reasoning turn", async
|
|
|
194
193
|
payload: { chatId: 123, prompt: "run harvest" }
|
|
195
194
|
});
|
|
196
195
|
|
|
197
|
-
assert.deepEqual(calls[0], ["
|
|
198
|
-
assert.deepEqual(calls[1], ["runTool", {
|
|
196
|
+
assert.deepEqual(calls[0], ["runTool", {
|
|
199
197
|
name: "creator-scout",
|
|
200
198
|
request: { args: { action: "status" } },
|
|
201
199
|
chatId: 123
|
|
@@ -335,9 +333,8 @@ test("due-task dispatch retries one failure without blocking another task", asyn
|
|
|
335
333
|
|
|
336
334
|
await dispatcher.dispatchDueTasks();
|
|
337
335
|
|
|
338
|
-
assert.deepEqual(calls.slice(0,
|
|
336
|
+
assert.deepEqual(calls.slice(0, 2), [
|
|
339
337
|
["claimDue", 10],
|
|
340
|
-
["runTurn", { priority: "background", label: "poll tool checker" }],
|
|
341
338
|
["runTool", { name: "checker", request: { args: {} }, chatId: 123 }]
|
|
342
339
|
]);
|
|
343
340
|
assert.ok(calls.some((call) => JSON.stringify(call) === JSON.stringify(["complete", "good"])));
|