arisa 5.2.20 → 5.2.22
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/agent/auth-flow.js +9 -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 +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/src/transport/telegram/telegram-auth-controller.js +20 -16
- 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-auth-controller.test.js +124 -0
- package/test/telegram-task-dispatcher.test.js +2 -5
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
|
@@ -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
|
|
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:
|
|
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 }));
|
|
@@ -33,6 +33,15 @@ export function getPiAuthIssue(error) {
|
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export function buildPiRequestFailureMessage(error) {
|
|
37
|
+
const message = getErrorMessage(error);
|
|
38
|
+
const limited = /usage limit|quota|rate[ _-]?limit|too many requests|insufficient[_ ]credits|credits? (?:exhausted|depleted)/i.test(message);
|
|
39
|
+
const title = limited
|
|
40
|
+
? "The provider's usage quota or request limit was reached. Retry when capacity is available; signing in again does not restore quota."
|
|
41
|
+
: "Pi request could not be completed. This does not establish an authentication failure.";
|
|
42
|
+
return `${title}\nDetails: ${message}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
export async function getPiAuthStatus(config, chatId = null) {
|
|
37
46
|
const runtime = await createPiRuntime({
|
|
38
47
|
provider: config.pi.provider,
|
|
@@ -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 {
|
|
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.
|
|
157
|
+
constructor(storage = {}) {
|
|
158
|
+
this.storage = storage;
|
|
198
159
|
}
|
|
199
160
|
|
|
200
161
|
async init() {
|
|
201
|
-
|
|
162
|
+
this.read(() => undefined);
|
|
202
163
|
}
|
|
203
164
|
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|