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 CHANGED
@@ -20,6 +20,18 @@ A committed database has schema version 1. Subsequent operations do not read or
20
20
 
21
21
  Back up the SQLite database with SQLite's backup API, or stop all writers and copy it along with any journal files. Preserve artifact files separately. A rollback to an older JSON-only core requires stopping all writers, backing up the database, and streaming `SELECT data FROM artifacts ORDER BY seq` into a new JSON array. Fsync and atomically replace the legacy index only after export succeeds. Do not simply revert the code and resume writes to the old JSON snapshot.
22
22
 
23
+ ## Task storage
24
+
25
+ `tasksDatabaseFile` identifies the live global scheduler database (`tasks.sqlite`); `tasksFile` is now only the legacy `tasks.json` migration source. Tools must use task IPC rather than read either file directly.
26
+
27
+ - Due claims use a partial index for pending/authentication-blocked tasks. ID mutations only load and update the selected row; an idle poll does not parse terminal history or rewrite the queue.
28
+ - Claim/read/update occurs in one synchronous `BEGIN IMMEDIATE` transaction, including across processes. Connections close after each operation, with a 1 MiB page cache, mmap disabled, FULL synchronous commits and a 5-second lock timeout. No asynchronous work runs while holding the write lock.
29
+ - First access imports the legacy array atomically and records schema version 1. This one-time import still loads the legacy array in memory. Startup recovery and unbounded history listing also remain proportional to history size; they are not the per-second hot path.
30
+ - Missing legacy input means a fresh database. Malformed input, duplicate IDs, invalid identities, unsupported schemas and unreadable databases fail explicitly instead of silently becoming an empty queue.
31
+ - The legacy file stays unchanged and is never reimported after a successful migration. Routes, auth blocks, retry state and interrupted-execution semantics are preserved. Do not run old JSON-writing workers alongside a migrated scheduler.
32
+
33
+ Back up using SQLite's backup API or stop all writers before copying the database and any journal. To downgrade, stop all writers and export `SELECT data FROM tasks ORDER BY seq` into a new UTF-8 JSON array, fsync and atomically replace `tasks.json` before starting the old core. The retained legacy JSON does not include subsequent changes and is not a safe downgrade by itself.
34
+
23
35
  ## Daemons
24
36
 
25
37
  Keep external ingress daemons running. Request-driven tools should use `autoStart: false` in both their manifest and runtime registration, start through the shared runtime when invoked, and stop when idle. Otherwise the supervisor will repeatedly restart a daemon that deliberately stopped for inactivity. Do not disable ingress or drop sessions to improve a memory benchmark.
@@ -32,6 +44,6 @@ Run the test suite serially on 1 GiB hosts:
32
44
  node --test --test-concurrency=1
33
45
  ```
34
46
 
35
- `test/artifact-index-memory.test.js` migrates a 100 MiB index and runs 300 subsequent operations in a child with a 48 MiB V8 heap. Migration tests cover UTF-8 chunk boundaries, escaping, corruption, rollback/retry, stable IDs, ordering and concurrent writers in separate processes. `test/cli-memory.test.js` runs the status command with a 24 MiB heap.
47
+ `test/artifact-index-memory.test.js` migrates a 100 MiB index and runs 300 subsequent operations in a child with a 48 MiB V8 heap. Migration tests cover UTF-8 chunk boundaries, escaping, corruption, rollback/retry, stable IDs, ordering and concurrent writers in separate processes. `test/cli-memory.test.js` runs the status command with a 24 MiB heap. `test/task-database.test.js` checks atomic migration, corruption, separate-process claims and 200 idle polls under a 32 MiB heap with approximately 60 MB of terminal history; idle polling must not rewrite the database.
36
48
 
37
49
  After deployment, check a real scheduled tool result, PID continuity, daemon health, `free -h`, `vmstat 1`, and `/proc/pressure/memory`. Short observations cannot establish long-term stability or guarantee that every browser workload fits this host.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.2.20",
3
+ "version": "5.2.21",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -30,6 +30,12 @@ export class AgentTurnCoordinator {
30
30
  this.expired = 0;
31
31
  this.maxObservedQueue = 0;
32
32
  this.totalWaitMs = 0;
33
+ this.lastInteractiveActivityAt = 0;
34
+ this.backgroundWakeTimer = null;
35
+ this.priorityStats = {
36
+ interactive: { queued: 0, started: 0, completed: 0, expired: 0, totalWaitMs: 0, maxWaitMs: 0 },
37
+ background: { queued: 0, started: 0, completed: 0, expired: 0, totalWaitMs: 0, maxWaitMs: 0 }
38
+ };
33
39
  this.setConfig(config);
34
40
  }
35
41
 
@@ -38,6 +44,7 @@ export class AgentTurnCoordinator {
38
44
  enabled: config.enabled !== false,
39
45
  backgroundQueueTtlMs: positiveInteger(config.backgroundQueueTtlMs, 10 * 60_000, 60 * 60_000),
40
46
  interactiveQueueTtlMs: positiveInteger(config.interactiveQueueTtlMs, 0, 60 * 60_000),
47
+ interactiveQuietMs: positiveInteger(config.interactiveQuietMs, 0, 60_000),
41
48
  maxQueued: Math.max(1, positiveInteger(config.maxQueued, 100, 1_000))
42
49
  };
43
50
  }
@@ -53,14 +60,24 @@ export class AgentTurnCoordinator {
53
60
  }
54
61
 
55
62
  diagnostic() {
63
+ const priorities = Object.fromEntries(Object.entries(this.priorityStats).map(([name, stats]) => [name, {
64
+ queued: stats.queued,
65
+ started: stats.started,
66
+ completed: stats.completed,
67
+ expired: stats.expired,
68
+ averageWaitMs: stats.started ? Math.round(stats.totalWaitMs / stats.started) : 0,
69
+ maxWaitMs: stats.maxWaitMs
70
+ }]));
56
71
  return {
57
72
  enabled: this.config.enabled,
58
73
  active: this.describeActive(this.active),
59
74
  queued: this.queue.length,
75
+ queuedInteractive: this.queue.filter((entry) => entry.priorityName === "interactive").length,
60
76
  maxObservedQueue: this.maxObservedQueue,
61
77
  completed: this.completed,
62
78
  expired: this.expired,
63
- averageWaitMs: this.completed ? Math.round(this.totalWaitMs / this.completed) : 0
79
+ averageWaitMs: this.completed ? Math.round(this.totalWaitMs / this.completed) : 0,
80
+ priorities
64
81
  };
65
82
  }
66
83
 
@@ -69,14 +86,42 @@ export class AgentTurnCoordinator {
69
86
  if (index >= 0) this.queue.splice(index, 1);
70
87
  }
71
88
 
89
+ clearBackgroundWake() {
90
+ if (!this.backgroundWakeTimer) return;
91
+ clearTimeout(this.backgroundWakeTimer);
92
+ this.backgroundWakeTimer = null;
93
+ }
94
+
95
+ deferBackground(delayMs) {
96
+ if (this.backgroundWakeTimer) return;
97
+ this.backgroundWakeTimer = setTimeout(() => {
98
+ this.backgroundWakeTimer = null;
99
+ this.next();
100
+ }, delayMs);
101
+ this.backgroundWakeTimer.unref?.();
102
+ }
103
+
72
104
  next() {
73
105
  if (this.active || this.closed || !this.queue.length) return;
74
106
  this.queue.sort((left, right) => right.priority - left.priority || left.sequence - right.sequence);
75
- const entry = this.queue.shift();
107
+ const entry = this.queue[0];
108
+ if (entry.priorityName === "background") {
109
+ const quietRemainingMs = this.lastInteractiveActivityAt + this.config.interactiveQuietMs - this.now();
110
+ if (quietRemainingMs > 0) {
111
+ this.deferBackground(quietRemainingMs);
112
+ return;
113
+ }
114
+ }
115
+ this.clearBackgroundWake();
116
+ this.queue.shift();
76
117
  if (entry.timer) clearTimeout(entry.timer);
77
118
  entry.startedAt = new Date(this.now()).toISOString();
78
119
  this.active = entry;
79
120
  const waitMs = Math.max(0, this.now() - entry.queuedAt);
121
+ const stats = this.priorityStats[entry.priorityName];
122
+ stats.started += 1;
123
+ stats.totalWaitMs += waitMs;
124
+ stats.maxWaitMs = Math.max(stats.maxWaitMs, waitMs);
80
125
  this.totalWaitMs += waitMs;
81
126
  if (waitMs > 0) this.logger?.log("agent", `${entry.label} waited ${waitMs}ms for exclusive agent execution`);
82
127
  let released = false;
@@ -85,6 +130,8 @@ export class AgentTurnCoordinator {
85
130
  released = true;
86
131
  if (this.active === entry) this.active = null;
87
132
  this.completed += 1;
133
+ stats.completed += 1;
134
+ if (entry.priorityName === "interactive") this.lastInteractiveActivityAt = this.now();
88
135
  queueMicrotask(() => this.next());
89
136
  });
90
137
  }
@@ -96,11 +143,12 @@ export class AgentTurnCoordinator {
96
143
  return Promise.reject(Object.assign(new Error("Agent turn queue is full."), { code: "AGENT_TURN_QUEUE_FULL", retryable: true }));
97
144
  }
98
145
  const priority = normalizedPriority(priorityName);
146
+ const normalizedPriorityName = priorityName === "interactive" ? "interactive" : "background";
99
147
  const ttlMs = this.queueTtlMs(priorityName, queueTtlMs);
100
148
  return new Promise((resolve, reject) => {
101
149
  const entry = {
102
150
  label: String(label || "Agent turn").slice(0, 160),
103
- priorityName: priorityName === "interactive" ? "interactive" : "background",
151
+ priorityName: normalizedPriorityName,
104
152
  priority,
105
153
  sequence: this.sequence += 1,
106
154
  queuedAt: this.now(),
@@ -114,11 +162,18 @@ export class AgentTurnCoordinator {
114
162
  if (this.active === entry) return;
115
163
  this.remove(entry);
116
164
  this.expired += 1;
165
+ this.priorityStats[entry.priorityName].expired += 1;
117
166
  reject(queueExpiredError(entry.label));
167
+ queueMicrotask(() => this.next());
118
168
  }, ttlMs);
119
169
  entry.timer.unref?.();
120
170
  }
121
171
  this.queue.push(entry);
172
+ this.priorityStats[entry.priorityName].queued += 1;
173
+ if (entry.priorityName === "interactive") {
174
+ this.lastInteractiveActivityAt = this.now();
175
+ this.clearBackgroundWake();
176
+ }
122
177
  this.maxObservedQueue = Math.max(this.maxObservedQueue, this.queue.length);
123
178
  this.next();
124
179
  });
@@ -135,6 +190,7 @@ export class AgentTurnCoordinator {
135
190
 
136
191
  close() {
137
192
  this.closed = true;
193
+ this.clearBackgroundWake();
138
194
  for (const entry of this.queue.splice(0)) {
139
195
  if (entry.timer) clearTimeout(entry.timer);
140
196
  entry.reject(Object.assign(new Error("Agent turn coordinator closed before execution."), { retryable: true }));
@@ -12,6 +12,9 @@ export const daemonConfigDefaults = Object.freeze({
12
12
  startupTimeoutMs: 120_000,
13
13
  stopTimeoutMs: 3_000,
14
14
  queuePollIntervalMs: 250,
15
+ journalRetentionMs: 24 * 60 * 60_000,
16
+ journalMaxCompleted: 2_048,
17
+ journalSweepIntervalMs: 5 * 60_000,
15
18
  streamBufferBytes: 1_048_576,
16
19
  ipcFrameBytes: 1_048_576
17
20
  });
@@ -94,6 +97,7 @@ export const piConfigDefaults = Object.freeze({
94
97
  enabled: true,
95
98
  backgroundQueueTtlMs: 10 * 60_000,
96
99
  interactiveQueueTtlMs: 0,
100
+ interactiveQuietMs: 2_000,
97
101
  maxQueued: 100
98
102
  }),
99
103
  compaction: Object.freeze({
@@ -0,0 +1,128 @@
1
+ import { chmodSync, mkdirSync, readFileSync, closeSync, openSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { tasksDatabaseFile, tasksFile } from "../../platform/paths.js";
5
+
6
+ function createPrivateFile(file) {
7
+ mkdirSync(path.dirname(file), { recursive: true });
8
+ try {
9
+ closeSync(openSync(file, "wx", 0o600));
10
+ } catch (error) {
11
+ if (error.code !== "EEXIST") throw error;
12
+ }
13
+ chmodSync(file, 0o600);
14
+ }
15
+
16
+ function legacyTasks(file) {
17
+ let text;
18
+ try {
19
+ text = readFileSync(file, "utf8");
20
+ } catch (error) {
21
+ if (error.code === "ENOENT") return [];
22
+ throw error;
23
+ }
24
+ const tasks = JSON.parse(text);
25
+ if (!Array.isArray(tasks)) throw new Error("Legacy task storage must contain an array");
26
+ return tasks;
27
+ }
28
+
29
+ function taskValues(task) {
30
+ if (!task || typeof task.id !== "string" || !task.id || typeof task.status !== "string") {
31
+ throw new Error("Invalid task identity or status");
32
+ }
33
+ return [task.id, task.status, String(task.payload?.chatId ?? ""), task.kind ?? null,
34
+ Number.isFinite(Date.parse(task.runAt)) ? Date.parse(task.runAt) : null,
35
+ Number.isFinite(Date.parse(task.createdAt)) ? Date.parse(task.createdAt) : 0,
36
+ JSON.stringify(task)];
37
+ }
38
+
39
+ export function insertTask(db, task) {
40
+ db.prepare("INSERT INTO tasks (id, status, chat_id, kind, run_at, created_at, data) VALUES (?, ?, ?, ?, ?, ?, ?)")
41
+ .run(...taskValues(task));
42
+ }
43
+
44
+ export function transaction(db, operation) {
45
+ db.exec("BEGIN IMMEDIATE");
46
+ try {
47
+ const result = operation();
48
+ db.exec("COMMIT");
49
+ return result;
50
+ } catch (error) {
51
+ db.exec("ROLLBACK");
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ function initialize(db, legacyFile, migrate) {
57
+ const version = () => db.prepare("PRAGMA user_version").get().user_version;
58
+ if (version() === 1) return;
59
+ if (version() !== 0) throw new Error("Unsupported task database version");
60
+ transaction(db, () => {
61
+ if (version() !== 0) return;
62
+ db.exec(`CREATE TABLE tasks (
63
+ seq INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE, status TEXT NOT NULL,
64
+ chat_id TEXT NOT NULL, kind TEXT, run_at INTEGER, created_at INTEGER NOT NULL, data TEXT NOT NULL
65
+ );
66
+ CREATE INDEX tasks_due ON tasks(run_at, created_at, id) WHERE status IN ('pending', 'blocked_auth');
67
+ CREATE INDEX tasks_chat ON tasks(chat_id, status);
68
+ CREATE INDEX tasks_status ON tasks(status);`);
69
+ for (const task of legacyTasks(legacyFile)) insertTask(db, migrate(task));
70
+ db.exec("PRAGMA user_version = 1");
71
+ });
72
+ }
73
+
74
+ // All work is synchronous inside a connection/transaction. No lock is held across
75
+ // an await; separate processes use SQLite locking, not an in-process promise queue.
76
+ export function withTaskDatabase(migrate, operation, {
77
+ databaseFile = tasksDatabaseFile, legacyFile = tasksFile
78
+ } = {}) {
79
+ createPrivateFile(databaseFile);
80
+ const db = new DatabaseSync(databaseFile);
81
+ try {
82
+ db.exec("PRAGMA busy_timeout = 5000; PRAGMA cache_size = -1024; PRAGMA mmap_size = 0; PRAGMA synchronous = FULL");
83
+ initialize(db, legacyFile, migrate);
84
+ return operation(db);
85
+ } catch (error) {
86
+ throw new Error(`Task storage operation failed: ${databaseFile}`, { cause: error });
87
+ } finally {
88
+ db.close();
89
+ }
90
+ }
91
+
92
+ export function selectTasks(db, filter = {}) {
93
+ if (filter.empty) return [];
94
+ const conditions = [];
95
+ const values = [];
96
+ for (const [key, column] of [["id", "id"], ["chatId", "chat_id"], ["status", "status"], ["kind", "kind"]]) {
97
+ if (filter[key]) {
98
+ conditions.push(`${column} = ?`);
99
+ values.push(String(filter[key]));
100
+ }
101
+ }
102
+ let order = "seq";
103
+ let limit = "";
104
+ if (filter.due) {
105
+ conditions.push("status IN ('pending', 'blocked_auth')", "run_at <= ?");
106
+ values.push(filter.due.now);
107
+ order = "run_at, created_at, id";
108
+ limit = " LIMIT ?";
109
+ values.push(filter.due.limit);
110
+ }
111
+ return db.prepare(`SELECT data FROM tasks ${filter.due ? "INDEXED BY tasks_due" : ""} ${conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""} ORDER BY ${order}${limit}`)
112
+ .all(...values).map((row) => JSON.parse(row.data));
113
+ }
114
+
115
+ export function persistTaskChanges(db, before, tasks) {
116
+ const remaining = new Set(tasks.map((task) => task.id));
117
+ for (const id of before.keys()) {
118
+ if (!remaining.has(id)) db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
119
+ }
120
+ for (const task of tasks) {
121
+ if (!before.has(task.id)) insertTask(db, task);
122
+ else if (before.get(task.id) !== JSON.stringify(task)) {
123
+ const [id, ...values] = taskValues(task);
124
+ db.prepare("UPDATE tasks SET status = ?, chat_id = ?, kind = ?, run_at = ?, created_at = ?, data = ? WHERE id = ?")
125
+ .run(...values, id);
126
+ }
127
+ }
128
+ }
@@ -1,7 +1,5 @@
1
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
- import path from "node:path";
3
1
  import crypto from "node:crypto";
4
- import { tasksFile } from "../../platform/paths.js";
2
+ import { withTaskDatabase, transaction, selectTasks, persistTaskChanges } from "./task-database.js";
5
3
 
6
4
  const DEFAULT_RETRY = Object.freeze({
7
5
  maxAttempts: 3,
@@ -13,43 +11,6 @@ const DEFAULT_RETRY = Object.freeze({
13
11
  const TERMINAL_STATUSES = new Set(["done", "failed", "outcome_uncertain"]);
14
12
  const TERMINAL_PAYLOAD_KEYS = ["chatId", "toolName", "resourceId", "artifactId"];
15
13
 
16
- const taskFileOperations = new Map();
17
-
18
- async function serializeTaskFileOperation(operation) {
19
- const previous = taskFileOperations.get(tasksFile) || Promise.resolve();
20
- const current = previous.catch(() => {}).then(operation);
21
- taskFileOperations.set(tasksFile, current);
22
- try {
23
- return await current;
24
- } finally {
25
- if (taskFileOperations.get(tasksFile) === current) taskFileOperations.delete(tasksFile);
26
- }
27
- }
28
-
29
- async function waitForTaskFileOperations() {
30
- await (taskFileOperations.get(tasksFile) || Promise.resolve()).catch(() => {});
31
- }
32
-
33
- async function loadTasksFile() {
34
- try {
35
- const parsed = JSON.parse(await readFile(tasksFile, "utf8"));
36
- return Array.isArray(parsed) ? parsed.map(migrateTask) : [];
37
- } catch {
38
- return [];
39
- }
40
- }
41
-
42
- async function saveTasksFile(tasks) {
43
- await mkdir(path.dirname(tasksFile), { recursive: true });
44
- const temporaryFile = `${tasksFile}.${process.pid}.${crypto.randomUUID()}.tmp`;
45
- try {
46
- await writeFile(temporaryFile, `${JSON.stringify(tasks, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
47
- await rename(temporaryFile, tasksFile);
48
- } finally {
49
- await rm(temporaryFile, { force: true }).catch(() => {});
50
- }
51
- }
52
-
53
14
  function taskId() {
54
15
  return crypto.randomUUID();
55
16
  }
@@ -193,64 +154,51 @@ function failTask(task, error) {
193
154
  }
194
155
 
195
156
  export class TaskStore {
196
- constructor() {
197
- this.tasks = null;
157
+ constructor(storage = {}) {
158
+ this.storage = storage;
198
159
  }
199
160
 
200
161
  async init() {
201
- if (!this.tasks) await this.reload();
162
+ this.read(() => undefined);
202
163
  }
203
164
 
204
- async reload() {
205
- await waitForTaskFileOperations();
206
- this.tasks = await loadTasksFile();
165
+ read(operation) {
166
+ return withTaskDatabase(migrateTask, operation, this.storage);
207
167
  }
208
168
 
209
- async mutate(operation) {
210
- return serializeTaskFileOperation(async () => {
211
- this.tasks = await loadTasksFile();
212
- const { result, changed = true } = await operation(this.tasks);
213
- if (changed) await saveTasksFile(this.tasks);
169
+ async mutate(operation, filter = {}) {
170
+ return this.read((db) => transaction(db, () => {
171
+ const tasks = selectTasks(db, filter);
172
+ const before = new Map(tasks.map((task) => [task.id, JSON.stringify(task)]));
173
+ const outcome = operation(tasks);
174
+ if (outcome?.then) throw new Error("Task mutations must be synchronous");
175
+ const { result, changed = true } = outcome;
176
+ if (changed) persistTaskChanges(db, before, tasks);
214
177
  return result;
215
- });
216
- }
217
-
218
- async save() {
219
- const tasks = structuredClone(this.tasks || []);
220
- return serializeTaskFileOperation(async () => {
221
- this.tasks = tasks;
222
- await saveTasksFile(tasks);
223
- });
178
+ }));
224
179
  }
225
180
 
226
181
  async add(task, defaults = {}) {
227
- return this.mutate(async (tasks) => {
182
+ return this.mutate((tasks) => {
228
183
  const normalized = normalizeTask(task, defaults);
229
184
  tasks.push(normalized);
230
185
  return { result: structuredClone(normalized) };
231
- });
186
+ }, { empty: true });
232
187
  }
233
188
 
234
189
  async addMany(tasksToAdd = [], defaults = {}) {
235
- return this.mutate(async (tasks) => {
190
+ return this.mutate((tasks) => {
236
191
  const created = tasksToAdd.map((task) => normalizeTask(task, defaults));
237
192
  tasks.push(...created);
238
193
  return { result: structuredClone(created), changed: created.length > 0 };
239
- });
194
+ }, { empty: true });
240
195
  }
241
196
 
242
197
  async claimDue(limit = 10) {
243
- return this.mutate(async (tasks) => {
244
- const now = Date.now();
245
- const due = tasks
246
- .filter((task) => ["pending", "blocked_auth"].includes(task.status)
247
- && task.runAt
248
- && !Number.isNaN(Date.parse(task.runAt))
249
- && Date.parse(task.runAt) <= now)
250
- .sort((left, right) => Date.parse(left.runAt) - Date.parse(right.runAt)
251
- || Date.parse(left.createdAt || 0) - Date.parse(right.createdAt || 0)
252
- || String(left.id).localeCompare(String(right.id)))
253
- .slice(0, limit);
198
+ const now = Date.now();
199
+ if (!Number.isSafeInteger(limit) || limit < 0) throw new Error("Invalid task claim limit");
200
+ return this.mutate((tasks) => {
201
+ const due = tasks;
254
202
 
255
203
  const claimedAt = new Date(now).toISOString();
256
204
  for (const task of due) {
@@ -263,21 +211,21 @@ export class TaskStore {
263
211
  }
264
212
 
265
213
  return { result: structuredClone(due), changed: due.length > 0 };
266
- });
214
+ }, { due: { now, limit } });
267
215
  }
268
216
 
269
217
  async markExecutionStarted(taskId) {
270
- return this.mutate(async (tasks) => {
218
+ return this.mutate((tasks) => {
271
219
  const task = tasks.find((item) => item.id === taskId);
272
220
  if (!task || task.status !== "running") return { result: null, changed: false };
273
221
  task.executionStartedAt = new Date().toISOString();
274
222
  task.updatedAt = task.executionStartedAt;
275
223
  return { result: structuredClone(task) };
276
- });
224
+ }, { id: taskId });
277
225
  }
278
226
 
279
227
  async recoverInterrupted() {
280
- return this.mutate(async (tasks) => {
228
+ return this.mutate((tasks) => {
281
229
  const recovered = [];
282
230
  let compacted = false;
283
231
  const now = Date.now();
@@ -336,7 +284,7 @@ export class TaskStore {
336
284
  }
337
285
 
338
286
  async complete(taskId) {
339
- return this.mutate(async (tasks) => {
287
+ return this.mutate((tasks) => {
340
288
  const task = tasks.find((item) => item.id === taskId);
341
289
  if (!task) return { result: null, changed: false };
342
290
 
@@ -366,11 +314,11 @@ export class TaskStore {
366
314
  task.updatedAt = completedAt;
367
315
  compactTerminalTask(task);
368
316
  return { result: structuredClone(task) };
369
- });
317
+ }, { id: taskId });
370
318
  }
371
319
 
372
320
  async blockAuth(taskId, error, resolution = {}) {
373
- return this.mutate(async (tasks) => {
321
+ return this.mutate((tasks) => {
374
322
  const task = tasks.find((item) => item.id === taskId);
375
323
  if (!task) return { result: null, changed: false };
376
324
  const now = Date.now();
@@ -395,11 +343,11 @@ export class TaskStore {
395
343
  delete task.executionStartedAt;
396
344
  delete task.error;
397
345
  return { result: { ...structuredClone(task), authBlockedNew: !wasBlocked } };
398
- });
346
+ }, { id: taskId });
399
347
  }
400
348
 
401
349
  async retryOrFail(taskId, error, { retryable = true, outcomeUncertain = false } = {}) {
402
- return this.mutate(async (tasks) => {
350
+ return this.mutate((tasks) => {
403
351
  const task = tasks.find((item) => item.id === taskId);
404
352
  if (!task) return { result: null, changed: false };
405
353
  const message = error instanceof Error ? error.message : String(error);
@@ -465,45 +413,38 @@ export class TaskStore {
465
413
  delete task.claimedAt;
466
414
  delete task.executionStartedAt;
467
415
  return { result: structuredClone(task) };
468
- });
416
+ }, { id: taskId });
469
417
  }
470
418
 
471
419
  async fail(taskId, error) {
472
- return this.mutate(async (tasks) => {
420
+ return this.mutate((tasks) => {
473
421
  const task = tasks.find((item) => item.id === taskId);
474
422
  return task
475
423
  ? { result: failTask(task, error) }
476
424
  : { result: null, changed: false };
477
- });
425
+ }, { id: taskId });
478
426
  }
479
427
 
480
428
  async list(filter = {}) {
481
- await this.reload();
482
- return this.tasks.filter((task) => {
483
- if (filter.chatId && String(task.payload?.chatId) !== String(filter.chatId)) return false;
484
- if (filter.status && task.status !== filter.status) return false;
485
- if (filter.kind && task.kind !== filter.kind) return false;
486
- return true;
487
- }).map((task) => structuredClone(task));
429
+ return this.read((db) => selectTasks(db, filter));
488
430
  }
489
431
 
490
432
  async get(taskId) {
491
- await this.reload();
492
- const task = this.tasks.find((item) => item.id === taskId);
493
- return task ? structuredClone(task) : null;
433
+ if (typeof taskId !== "string" || !taskId) return null;
434
+ return this.read((db) => selectTasks(db, { id: taskId })[0] || null);
494
435
  }
495
436
 
496
437
  async cancel(taskId) {
497
- return this.mutate(async (tasks) => {
438
+ return this.mutate((tasks) => {
498
439
  const index = tasks.findIndex((item) => item.id === taskId);
499
440
  if (index === -1) return { result: null, changed: false };
500
441
  const [task] = tasks.splice(index, 1);
501
442
  return { result: structuredClone(task) };
502
- });
443
+ }, { id: taskId });
503
444
  }
504
445
 
505
446
  async cancelAll(filter = {}) {
506
- return this.mutate(async (tasks) => {
447
+ return this.mutate((tasks) => {
507
448
  const removed = [];
508
449
  const remaining = tasks.filter((task) => {
509
450
  if (filter.chatId && String(task.payload?.chatId) !== String(filter.chatId)) return true;
@@ -514,6 +455,6 @@ export class TaskStore {
514
455
  });
515
456
  tasks.splice(0, tasks.length, ...remaining);
516
457
  return { result: removed, changed: removed.length > 0 };
517
- });
458
+ }, filter);
518
459
  }
519
460
  }