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.
@@ -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"])));