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.
Files changed (37) hide show
  1. package/LOW-MEMORY.md +49 -0
  2. package/README.md +2 -1
  3. package/package.json +7 -8
  4. package/src/core/agent/agent-turn-coordinator.js +59 -3
  5. package/src/core/agent/model-selection.js +4 -3
  6. package/src/core/agent/model-speed.js +11 -3
  7. package/src/core/artifacts/artifact-index.js +107 -0
  8. package/src/core/artifacts/artifact-store.js +15 -84
  9. package/src/core/artifacts/legacy-artifact-reader.js +46 -0
  10. package/src/core/config/config-defaults.js +4 -0
  11. package/src/core/tasks/task-database.js +128 -0
  12. package/src/core/tasks/task-store.js +42 -101
  13. package/src/core/tools/daemon-journal.js +115 -0
  14. package/src/core/tools/daemon-processes.js +10 -1
  15. package/src/core/tools/daemon-protocol.js +1 -1
  16. package/src/core/tools/daemon-worker.js +37 -5
  17. package/src/index.js +45 -5
  18. package/src/platform/paths.js +7 -1
  19. package/src/runtime/worker-recovery-report.js +7 -4
  20. package/src/transport/telegram/model-callback.js +3 -2
  21. package/src/transport/telegram/model-controls.js +3 -3
  22. package/src/transport/telegram/model-picker.js +1 -1
  23. package/src/transport/telegram/task-dispatcher.js +8 -8
  24. package/test/agent-turn-coordinator.test.js +48 -0
  25. package/test/artifact-index-memory.test.js +46 -0
  26. package/test/artifact-index-migration.test.js +88 -0
  27. package/test/artifact-store.test.js +3 -3
  28. package/test/cli-command.test.js +52 -0
  29. package/test/cli-memory.test.js +22 -0
  30. package/test/daemon-runtime.test.js +44 -4
  31. package/test/model-selection.test.js +11 -2
  32. package/test/paths.test.js +2 -0
  33. package/test/pi-compaction.test.js +1 -0
  34. package/test/pi-speed-integration.test.js +9 -8
  35. package/test/task-database.test.js +130 -0
  36. package/test/task-store.test.js +10 -5
  37. 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:2"), null);
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.throws(() => normalizeModelSpeed(2), /Invalid model speed/);
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 () => {
@@ -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", () => {
@@ -40,6 +40,7 @@ test("merges partial exclusive turn coordinator overrides with defaults", () =>
40
40
  enabled: true,
41
41
  backgroundQueueTtlMs: 30_000,
42
42
  interactiveQueueTtlMs: 0,
43
+ interactiveQuietMs: 2_000,
43
44
  maxQueued: 100
44
45
  });
45
46
  });
@@ -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:1.5");
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", 1.5]]);
58
- assert.equal(resolveChatSpeed(writes[0], "123:topic:7"), 1.5);
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 1.5x/);
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 === 1.5 ? "priority" : "default");
109
+ assert.equal(requests.at(-1).service_tier, speed > 1 ? "priority" : "default");
109
110
  }
110
- assert.equal(requests.length, 3);
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, 1.5);
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
+ });
@@ -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.tasks.find((task) => task.id === "auth-poll").runAt = new Date(Date.now() - 1000).toISOString();
148
- await store.save();
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 = JSON.parse(await readFile(tasksFile, "utf8"));
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.tasks.find((task) => task.id === "retrying").runAt = runAt;
282
- await store.save();
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], ["runTurn", { priority: "background", label: "authentication probe creator-scout" }]);
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, 3), [
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"])));