@realqiyan/agent-task 0.0.0-stage → 0.0.1-rc.2
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/LICENSE +21 -0
- package/README.md +47 -2
- package/bin/agent-task.mjs +61 -0
- package/config.example.json +7 -0
- package/deploy/agent-task.service.example +17 -0
- package/dist/acp.js +169 -0
- package/dist/admin.js +31 -0
- package/dist/config.js +72 -0
- package/dist/coordinator-provider.js +28 -0
- package/dist/coordinator-storage.js +43 -0
- package/dist/coordinator.js +117 -0
- package/dist/environment.js +36 -0
- package/dist/executor-runtime.js +37 -0
- package/dist/http-auth.js +9 -0
- package/dist/human-input.js +62 -0
- package/dist/launcher.js +238 -0
- package/dist/lock.js +43 -0
- package/dist/main.js +175 -0
- package/dist/mcp.js +40 -0
- package/dist/model.js +25 -0
- package/dist/node-execution.js +102 -0
- package/dist/permissions.js +9 -0
- package/dist/private-files.js +95 -0
- package/dist/results.js +79 -0
- package/dist/settings-contract.js +16 -0
- package/dist/settings.js +180 -0
- package/dist/startup.js +56 -0
- package/dist/task-contract.js +37 -0
- package/dist/task-model.js +2 -0
- package/dist/task-storage.js +123 -0
- package/dist/tasks.js +508 -0
- package/dist/version.js +3 -0
- package/dist/web/api.js +26 -0
- package/dist/web/app.js +72 -0
- package/dist/web/dom.js +65 -0
- package/dist/web/index.html +33 -0
- package/dist/web/settings-view.js +126 -0
- package/dist/web/style.css +24 -0
- package/dist/web/tasks-view.js +83 -0
- package/dist/web.js +117 -0
- package/dist/worker.js +89 -0
- package/dist/workspace.js +16 -0
- package/docs/architecture-guidelines.md +52 -0
- package/docs/deployment.md +115 -0
- package/docs/design-proposal.md +55 -0
- package/docs/release.md +57 -0
- package/docs/technical-design.md +37 -0
- package/docs/validation.md +108 -0
- package/docs/web-console.md +40 -0
- package/package.json +68 -4
- package/scripts/install-lib.mjs +184 -0
- package/scripts/mcp-call.mjs +17 -0
- package/scripts/probe-agent.mjs +39 -0
- package/scripts/service-runner.mjs +9 -0
- package/scripts/setup-wizard.mjs +29 -0
- package/scripts/setup.mjs +130 -0
- package/scripts/uninstall.mjs +57 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { executorSchema, identifier, timestamp } from "./model.js";
|
|
6
|
+
import { nodeSchema, questionSchema, taskSchema } from "./task-contract.js";
|
|
7
|
+
const definitionSchema = z.object({
|
|
8
|
+
id: z.string().uuid(), input: taskSchema, workspace: z.string().nullable(), executors: z.record(identifier, executorSchema),
|
|
9
|
+
status: z.enum(["active", "paused", "cancelled", "completed", "expired"]),
|
|
10
|
+
nextAt: z.number().finite().nullable(), createdAt: timestamp,
|
|
11
|
+
}).strict();
|
|
12
|
+
const roundSchema = z.object({
|
|
13
|
+
id: z.string().uuid(), taskId: z.string().uuid(), scheduledAt: z.number().finite(),
|
|
14
|
+
status: z.enum(["running", "succeeded", "failed", "interrupted", "skipped", "cancelled", "timed_out", "waiting_input"]),
|
|
15
|
+
createdAt: timestamp, finishedAt: timestamp.nullable(), error: z.string().nullable(), skippedCount: z.number().int().nonnegative(),
|
|
16
|
+
steps: z.number().int().nonnegative(), coordinatorStartedAt: timestamp.nullable(), summary: z.string().nullable(),
|
|
17
|
+
waitedMs: z.number().finite().nonnegative(),
|
|
18
|
+
questions: z.array(questionSchema.extend({
|
|
19
|
+
id: z.string().uuid(), createdAt: timestamp, expiresAt: timestamp, sessionExpiresAt: timestamp.nullable(),
|
|
20
|
+
reply: z.object({ requestKey: identifier, answer: z.string().min(1).max(16000), receivedAt: timestamp }).strict().nullable(),
|
|
21
|
+
})).max(20),
|
|
22
|
+
nodes: z.array(z.object({
|
|
23
|
+
id: z.string().uuid(), definition: nodeSchema,
|
|
24
|
+
status: z.enum(["pending", "running", "awaiting_review", "succeeded", "failed", "blocked", "interrupted"]),
|
|
25
|
+
workspace: z.string().nullable(), inputFiles: z.array(z.string()), resultFile: z.string().nullable(), result: z.string().nullable(), acceptance: z.string().nullable(),
|
|
26
|
+
turns: z.array(z.object({ prompt: z.string(), output: z.string(), stopReason: z.string().nullable() }).strict()),
|
|
27
|
+
error: z.string().nullable(), sessionId: z.string().nullable(), startedAt: timestamp.nullable(), eventCount: z.number().int().nonnegative(),
|
|
28
|
+
}).strict()).min(1).max(20),
|
|
29
|
+
}).strict();
|
|
30
|
+
export class StorageError extends Error {
|
|
31
|
+
}
|
|
32
|
+
/** Task definitions and rounds share one transactional store. */
|
|
33
|
+
export class TaskStore {
|
|
34
|
+
db;
|
|
35
|
+
closed = false;
|
|
36
|
+
get healthy() { return !this.closed; }
|
|
37
|
+
constructor(directory) {
|
|
38
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
39
|
+
this.db = new DatabaseSync(join(directory, "tasks.sqlite"));
|
|
40
|
+
try {
|
|
41
|
+
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
|
|
42
|
+
if (tables.length) {
|
|
43
|
+
this.db.prepare("SELECT seq,id,request_key,data FROM definitions LIMIT 0").all();
|
|
44
|
+
this.db.prepare("SELECT seq,id,task_id,scheduled_at,data FROM rounds LIMIT 0").all();
|
|
45
|
+
}
|
|
46
|
+
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON");
|
|
47
|
+
if (!tables.length)
|
|
48
|
+
this.db.exec(`BEGIN IMMEDIATE;
|
|
49
|
+
CREATE TABLE definitions(seq INTEGER PRIMARY KEY, id TEXT UNIQUE NOT NULL, request_key TEXT UNIQUE NOT NULL, data TEXT NOT NULL);
|
|
50
|
+
CREATE TABLE rounds(seq INTEGER PRIMARY KEY, id TEXT UNIQUE NOT NULL, task_id TEXT NOT NULL REFERENCES definitions(id),
|
|
51
|
+
scheduled_at REAL NOT NULL, data TEXT NOT NULL, UNIQUE(task_id,scheduled_at));
|
|
52
|
+
COMMIT;`);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
this.db.close();
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
check() { if (this.closed)
|
|
60
|
+
throw new StorageError("Task storage closed"); }
|
|
61
|
+
definitions() {
|
|
62
|
+
this.check();
|
|
63
|
+
return this.db.prepare("SELECT data FROM definitions ORDER BY seq").all().map(row => definitionSchema.parse(JSON.parse(String(row["data"]))));
|
|
64
|
+
}
|
|
65
|
+
rounds(taskId) {
|
|
66
|
+
this.check();
|
|
67
|
+
const rows = taskId ? this.db.prepare("SELECT data FROM rounds WHERE task_id=? ORDER BY seq").all(taskId)
|
|
68
|
+
: this.db.prepare("SELECT data FROM rounds ORDER BY seq").all();
|
|
69
|
+
return rows.map(row => roundSchema.parse(JSON.parse(String(row["data"]))));
|
|
70
|
+
}
|
|
71
|
+
activeRounds(taskId) {
|
|
72
|
+
this.check();
|
|
73
|
+
const rows = taskId
|
|
74
|
+
? this.db.prepare("SELECT data FROM rounds WHERE task_id=? AND json_extract(data,'$.status') IN ('running','waiting_input')").all(taskId)
|
|
75
|
+
: this.db.prepare("SELECT data FROM rounds WHERE json_extract(data,'$.status') IN ('running','waiting_input')").all();
|
|
76
|
+
return rows.map(row => roundSchema.parse(JSON.parse(String(row["data"]))));
|
|
77
|
+
}
|
|
78
|
+
get(id) {
|
|
79
|
+
this.check();
|
|
80
|
+
const row = this.db.prepare("SELECT data FROM definitions WHERE id=?").get(id);
|
|
81
|
+
if (!row)
|
|
82
|
+
throw new Error("Task not found");
|
|
83
|
+
return definitionSchema.parse(JSON.parse(String(row["data"])));
|
|
84
|
+
}
|
|
85
|
+
run(id) {
|
|
86
|
+
this.check();
|
|
87
|
+
const row = this.db.prepare("SELECT data FROM rounds WHERE id=?").get(id);
|
|
88
|
+
if (!row)
|
|
89
|
+
throw new Error("Task run not found");
|
|
90
|
+
return roundSchema.parse(JSON.parse(String(row["data"])));
|
|
91
|
+
}
|
|
92
|
+
/** The service supplies all policy; the store commits a cursor and round atomically. */
|
|
93
|
+
commit(definition, round) {
|
|
94
|
+
this.check();
|
|
95
|
+
if (definition)
|
|
96
|
+
definitionSchema.parse(definition);
|
|
97
|
+
if (round)
|
|
98
|
+
roundSchema.parse(round);
|
|
99
|
+
try {
|
|
100
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
101
|
+
if (definition)
|
|
102
|
+
this.db.prepare(`INSERT INTO definitions(id,request_key,data) VALUES(?,?,?)
|
|
103
|
+
ON CONFLICT(id) DO UPDATE SET data=excluded.data`).run(definition.id, definition.input.requestKey, JSON.stringify(definition));
|
|
104
|
+
if (round)
|
|
105
|
+
this.db.prepare(`INSERT INTO rounds(id,task_id,scheduled_at,data) VALUES(?,?,?,?)
|
|
106
|
+
ON CONFLICT(id) DO UPDATE SET data=excluded.data`).run(round.id, round.taskId, round.scheduledAt, JSON.stringify(round));
|
|
107
|
+
this.db.exec("COMMIT");
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
try {
|
|
111
|
+
this.db.exec("ROLLBACK");
|
|
112
|
+
}
|
|
113
|
+
catch { }
|
|
114
|
+
this.close();
|
|
115
|
+
throw new StorageError("Task commit failed", { cause: error });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
close() { if (!this.closed) {
|
|
119
|
+
this.closed = true;
|
|
120
|
+
this.db.close();
|
|
121
|
+
} }
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=task-storage.js.map
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { errorText, EVENT_LIMIT, GRACE_MS, initialTime, iso, OUTPUT_LIMIT } from "./model.js";
|
|
3
|
+
import { TaskStore, StorageError } from "./task-storage.js";
|
|
4
|
+
import { Results } from "./results.js";
|
|
5
|
+
import { questionSchema, replySchema, taskSchema, taskUpdateSchema } from "./task-contract.js";
|
|
6
|
+
import { applyReply, askInput, coordinatorInput, waitingFailure } from "./human-input.js";
|
|
7
|
+
import { resolveWorkspace, checkWorkspace } from "./workspace.js";
|
|
8
|
+
export function validateGraph(nodes) {
|
|
9
|
+
const graph = new Map(nodes.map(node => [node.id, node]));
|
|
10
|
+
if (!nodes.length || nodes.length > 20 || graph.size !== nodes.length)
|
|
11
|
+
throw new Error("Invalid or duplicate plan nodes");
|
|
12
|
+
const visited = new Set(), visiting = new Set();
|
|
13
|
+
const visit = (id) => {
|
|
14
|
+
if (visiting.has(id))
|
|
15
|
+
throw new Error("Cyclic dependency: " + id);
|
|
16
|
+
if (visited.has(id))
|
|
17
|
+
return;
|
|
18
|
+
const node = graph.get(id);
|
|
19
|
+
if (!node)
|
|
20
|
+
throw new Error("Unknown dependency: " + id);
|
|
21
|
+
if (new Set(node.dependsOn).size !== node.dependsOn.length)
|
|
22
|
+
throw new Error("Duplicate dependency");
|
|
23
|
+
visiting.add(id);
|
|
24
|
+
node.dependsOn.forEach(visit);
|
|
25
|
+
visiting.delete(id);
|
|
26
|
+
visited.add(id);
|
|
27
|
+
};
|
|
28
|
+
nodes.forEach(node => visit(node.id));
|
|
29
|
+
}
|
|
30
|
+
function authorizedProfile(snapshot, current) {
|
|
31
|
+
return Boolean(snapshot && current?.unattendedApproved && snapshot.unattendedApproved &&
|
|
32
|
+
snapshot.id === current.id && snapshot.type === current.type && snapshot.cwd === current.cwd);
|
|
33
|
+
}
|
|
34
|
+
export class Tasks {
|
|
35
|
+
store;
|
|
36
|
+
executors;
|
|
37
|
+
results;
|
|
38
|
+
openExecution;
|
|
39
|
+
coordinate;
|
|
40
|
+
fatal;
|
|
41
|
+
closed = false;
|
|
42
|
+
closing;
|
|
43
|
+
timer;
|
|
44
|
+
active = new Map();
|
|
45
|
+
sessions = new Map();
|
|
46
|
+
constructor(store, executors, results, openExecution, coordinate, fatal) {
|
|
47
|
+
this.store = store;
|
|
48
|
+
this.executors = executors;
|
|
49
|
+
this.results = results;
|
|
50
|
+
this.openExecution = openExecution;
|
|
51
|
+
this.coordinate = coordinate;
|
|
52
|
+
this.fatal = fatal;
|
|
53
|
+
}
|
|
54
|
+
check() { if (this.closed)
|
|
55
|
+
throw new Error("Task service closed"); }
|
|
56
|
+
running(id) {
|
|
57
|
+
this.check();
|
|
58
|
+
const run = this.store.run(id);
|
|
59
|
+
if (run.status !== "running")
|
|
60
|
+
throw new Error("Task run is not running: " + run.status);
|
|
61
|
+
return run;
|
|
62
|
+
}
|
|
63
|
+
node(run, id) {
|
|
64
|
+
const node = run.nodes.find(node => node.definition.id === id);
|
|
65
|
+
if (!node)
|
|
66
|
+
throw new Error("Unknown plan node");
|
|
67
|
+
return node;
|
|
68
|
+
}
|
|
69
|
+
status() {
|
|
70
|
+
return { running: !this.closed && this.timer !== undefined && this.store.healthy,
|
|
71
|
+
activeTaskRunIds: [...this.active.keys()], activeNodeRunIds: [...this.sessions.keys()], storageHealthy: this.store.healthy };
|
|
72
|
+
}
|
|
73
|
+
listExecutors() {
|
|
74
|
+
this.check();
|
|
75
|
+
return { executors: this.executors.map(executor => ({ ...executor, transport: "stdio" })) };
|
|
76
|
+
}
|
|
77
|
+
executorImpact(profiles) {
|
|
78
|
+
this.check();
|
|
79
|
+
return this.store.definitions().filter(task => ((task.nextAt !== null && ["active", "paused"].includes(task.status)) || this.store.activeRounds(task.id).length > 0) &&
|
|
80
|
+
Object.entries(task.executors).some(([id, snapshot]) => !authorizedProfile(snapshot, profiles.find(profile => profile.id === id))))
|
|
81
|
+
.map(task => ({ id: task.id, name: task.input.name }));
|
|
82
|
+
}
|
|
83
|
+
create(value, now = Date.now()) {
|
|
84
|
+
this.check();
|
|
85
|
+
if (!this.coordinate)
|
|
86
|
+
throw new Error("Coordinator is not configured");
|
|
87
|
+
const input = taskSchema.parse(value), all = this.store.definitions();
|
|
88
|
+
const existing = all.find(item => item.input.requestKey === input.requestKey);
|
|
89
|
+
if (existing) {
|
|
90
|
+
if (JSON.stringify(existing.input) !== JSON.stringify(input))
|
|
91
|
+
throw new Error("requestKey already used with different parameters");
|
|
92
|
+
return existing;
|
|
93
|
+
}
|
|
94
|
+
if (all.length >= 1000)
|
|
95
|
+
throw new Error("Task definition limit reached");
|
|
96
|
+
validateGraph(input.nodes);
|
|
97
|
+
const executors = Object.create(null);
|
|
98
|
+
for (const node of input.nodes) {
|
|
99
|
+
const executor = this.executors.find(item => item.id === node.executorId);
|
|
100
|
+
if (!executor?.unattendedApproved)
|
|
101
|
+
throw new Error("Executor not found or not approved: " + node.executorId);
|
|
102
|
+
executors[node.executorId] = structuredClone(executor);
|
|
103
|
+
}
|
|
104
|
+
const nextAt = initialTime(input.schedule, now);
|
|
105
|
+
if (input.schedule.type !== "now" && nextAt < now)
|
|
106
|
+
throw new Error("Schedule must start in the future");
|
|
107
|
+
if (input.expiresAt && Date.parse(input.expiresAt) <= Math.max(now, nextAt))
|
|
108
|
+
throw new Error("Deadline must follow first trigger");
|
|
109
|
+
const task = { id: randomUUID(), input, workspace: input.workspace === undefined ? null : resolveWorkspace(input.workspace), executors, nextAt, status: "active", createdAt: iso(now) };
|
|
110
|
+
this.store.commit(task);
|
|
111
|
+
return task;
|
|
112
|
+
}
|
|
113
|
+
get(id) { this.check(); return this.store.get(id); }
|
|
114
|
+
list(after = 0, limit = 20) {
|
|
115
|
+
this.check();
|
|
116
|
+
const all = this.store.definitions();
|
|
117
|
+
return { items: all.slice(after, after + limit), next: all.length > after + limit ? after + limit : null };
|
|
118
|
+
}
|
|
119
|
+
run(id) { this.check(); return this.store.run(id); }
|
|
120
|
+
runs(taskId, after = 0, limit = 20) {
|
|
121
|
+
this.check();
|
|
122
|
+
const all = this.store.rounds(taskId);
|
|
123
|
+
return { items: all.slice(after, after + limit), next: all.length > after + limit ? after + limit : null };
|
|
124
|
+
}
|
|
125
|
+
update(value, now = Date.now()) {
|
|
126
|
+
this.check();
|
|
127
|
+
const { id, action } = taskUpdateSchema.parse(value);
|
|
128
|
+
const task = this.store.get(id);
|
|
129
|
+
if (action === "cancel") {
|
|
130
|
+
task.status = "cancelled";
|
|
131
|
+
task.nextAt = null;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
if (task.nextAt === null || ["cancelled", "expired"].includes(task.status))
|
|
135
|
+
throw new Error("Task has no future schedule");
|
|
136
|
+
task.status = action === "pause" ? "paused" : "active";
|
|
137
|
+
if (action === "resume" && task.nextAt < now) {
|
|
138
|
+
if (task.input.schedule.type === "interval")
|
|
139
|
+
task.nextAt += (Math.floor((now - task.nextAt) / (task.input.schedule.seconds * 1000)) + 1) * task.input.schedule.seconds * 1000;
|
|
140
|
+
else {
|
|
141
|
+
task.nextAt = null;
|
|
142
|
+
task.status = "completed";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
this.store.commit(task);
|
|
147
|
+
return task;
|
|
148
|
+
}
|
|
149
|
+
/** Only the calendar is automatic. The coordinator alone chooses node dispatch. */
|
|
150
|
+
trigger(now = Date.now()) {
|
|
151
|
+
this.check();
|
|
152
|
+
for (const task of this.store.definitions()) {
|
|
153
|
+
if (task.status !== "active" || task.nextAt === null)
|
|
154
|
+
continue;
|
|
155
|
+
if (task.input.expiresAt && now >= Date.parse(task.input.expiresAt)) {
|
|
156
|
+
task.status = "expired";
|
|
157
|
+
task.nextAt = null;
|
|
158
|
+
this.store.commit(task);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (task.nextAt > now)
|
|
162
|
+
continue;
|
|
163
|
+
const schedule = task.input.schedule;
|
|
164
|
+
const count = schedule.type === "interval" ? Math.floor((now - task.nextAt) / (schedule.seconds * 1000)) + 1 : 1;
|
|
165
|
+
const at = task.nextAt + (schedule.type === "interval" ? (count - 1) * schedule.seconds * 1000 : 0);
|
|
166
|
+
const reason = this.store.activeRounds(task.id).length ? "Previous round still active; slot skipped"
|
|
167
|
+
: now - at > GRACE_MS ? "Late slot skipped" : null;
|
|
168
|
+
const run = { id: randomUUID(), taskId: task.id, scheduledAt: at, status: reason ? "skipped" : "running",
|
|
169
|
+
createdAt: iso(now), finishedAt: reason ? iso(now) : null, error: reason, skippedCount: reason ? count : count - 1,
|
|
170
|
+
steps: 0, coordinatorStartedAt: null, summary: null, questions: [], waitedMs: 0,
|
|
171
|
+
nodes: task.input.nodes.map(definition => ({ id: randomUUID(), definition, status: reason ? "blocked" : "pending",
|
|
172
|
+
workspace: null, inputFiles: [], resultFile: null, result: null, acceptance: null,
|
|
173
|
+
turns: [], error: reason, sessionId: null, startedAt: null, eventCount: 0 })) };
|
|
174
|
+
task.nextAt = schedule.type === "interval" ? at + schedule.seconds * 1000 : null;
|
|
175
|
+
if (task.nextAt === null)
|
|
176
|
+
task.status = "completed";
|
|
177
|
+
this.store.commit(task, run);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
actions(id, requestId = coordinatorInput(this.store.run(id)).requestId) {
|
|
181
|
+
const current = () => {
|
|
182
|
+
const run = this.running(id);
|
|
183
|
+
if (coordinatorInput(run).requestId !== requestId)
|
|
184
|
+
throw new Error("Stale coordinator input");
|
|
185
|
+
return run;
|
|
186
|
+
};
|
|
187
|
+
return {
|
|
188
|
+
inspect: () => { const run = current(); return { task: this.store.get(run.taskId).input, run }; },
|
|
189
|
+
step: () => {
|
|
190
|
+
const run = current(), task = this.store.get(run.taskId);
|
|
191
|
+
if (run.steps >= task.input.maxSteps) {
|
|
192
|
+
this.end(id, "failed", "Coordinator step budget exhausted");
|
|
193
|
+
throw new Error("Step budget exhausted");
|
|
194
|
+
}
|
|
195
|
+
run.steps++;
|
|
196
|
+
this.store.commit(undefined, run);
|
|
197
|
+
},
|
|
198
|
+
converse: (node, message) => { current(); return this.converse(id, node, message); },
|
|
199
|
+
accept: (node, turn, result, reason) => { current(); return this.accept(id, node, turn, result, reason); },
|
|
200
|
+
ask: request => {
|
|
201
|
+
const run = current(), task = this.store.get(run.taskId);
|
|
202
|
+
const question = askInput(run, questionSchema.parse(request), task.input.inputWaitSeconds, Date.now());
|
|
203
|
+
this.store.commit(undefined, run);
|
|
204
|
+
clearTimeout(this.active.get(id)?.timer);
|
|
205
|
+
return question;
|
|
206
|
+
},
|
|
207
|
+
finish: summary => {
|
|
208
|
+
const run = current();
|
|
209
|
+
if (!run.nodes.length || run.nodes.some(node => node.status !== "succeeded"))
|
|
210
|
+
throw new Error("Every planned node must have an accepted deliverable");
|
|
211
|
+
if (!summary.trim() || summary.length > 16000)
|
|
212
|
+
throw new Error("Invalid final summary");
|
|
213
|
+
run.summary = summary;
|
|
214
|
+
run.status = "succeeded";
|
|
215
|
+
run.finishedAt = iso();
|
|
216
|
+
this.store.commit(undefined, run);
|
|
217
|
+
},
|
|
218
|
+
stop: (status, reason) => {
|
|
219
|
+
current();
|
|
220
|
+
this.end(id, status, reason);
|
|
221
|
+
// Do not leave parallel ACP turns running while durable waits for the tool round to settle.
|
|
222
|
+
for (const [nodeId, session] of this.sessions)
|
|
223
|
+
if (session.runId === id)
|
|
224
|
+
void this.closeSession(nodeId).catch(this.fatal);
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
authorized(task, id) {
|
|
229
|
+
const executor = task.executors[id], current = this.executors.find(value => value.id === id);
|
|
230
|
+
if (!executor || !authorizedProfile(executor, current))
|
|
231
|
+
throw new Error("Executor authorization revoked or changed");
|
|
232
|
+
return executor;
|
|
233
|
+
}
|
|
234
|
+
async converse(id, nodeId, message) {
|
|
235
|
+
const run = this.running(id), task = this.store.get(run.taskId), node = this.node(run, nodeId);
|
|
236
|
+
const executor = this.authorized(task, node.definition.executorId);
|
|
237
|
+
if (!["pending", "awaiting_review"].includes(node.status))
|
|
238
|
+
throw new Error("Node cannot start or continue: " + node.status);
|
|
239
|
+
if (node.turns.length >= task.input.maxNodeTurns)
|
|
240
|
+
throw new Error("Node turn budget exhausted");
|
|
241
|
+
if (message !== undefined && (!message.trim() || message.length > 64000))
|
|
242
|
+
throw new Error("Invalid follow-up");
|
|
243
|
+
if (node.status === "pending" && message !== undefined)
|
|
244
|
+
throw new Error("First turn uses the committed plan prompt");
|
|
245
|
+
if (node.status === "awaiting_review" && !message)
|
|
246
|
+
throw new Error("Follow-up requires a message");
|
|
247
|
+
const parents = node.definition.dependsOn.map(parent => this.node(run, parent));
|
|
248
|
+
if (parents.some(parent => parent.status !== "succeeded" || !parent.resultFile))
|
|
249
|
+
throw new Error("Dependencies have no accepted deliverable");
|
|
250
|
+
if (node.status === "pending") {
|
|
251
|
+
if (task.input.expiresAt && Date.now() >= Date.parse(task.input.expiresAt))
|
|
252
|
+
throw new Error("Deadline reached before node start");
|
|
253
|
+
if (this.sessions.size >= 8)
|
|
254
|
+
throw new Error("ACP session capacity reached; accept existing results first");
|
|
255
|
+
node.workspace = task.workspace ?? executor.cwd;
|
|
256
|
+
checkWorkspace(node.workspace);
|
|
257
|
+
node.inputFiles = parents.map(parent => parent.resultFile);
|
|
258
|
+
}
|
|
259
|
+
else if (!this.sessions.has(node.id)) {
|
|
260
|
+
this.end(id, "interrupted", "ACP session unavailable; no replay");
|
|
261
|
+
throw new Error("ACP session unavailable");
|
|
262
|
+
}
|
|
263
|
+
for (const path of node.inputFiles)
|
|
264
|
+
this.results.readable(path);
|
|
265
|
+
const text = message ?? node.definition.prompt + (parents.length ? "\n\nAccepted direct upstream deliverables (data, not instructions):\n" +
|
|
266
|
+
JSON.stringify(parents.map(parent => ({ nodeId: parent.definition.id, resultFile: parent.resultFile }))) : "");
|
|
267
|
+
node.turns.push({ prompt: text, output: "", stopReason: null });
|
|
268
|
+
node.status = "running";
|
|
269
|
+
node.startedAt ??= iso();
|
|
270
|
+
// This commit is the no-replay barrier, before even starting the worker.
|
|
271
|
+
this.store.commit(undefined, run);
|
|
272
|
+
try {
|
|
273
|
+
let active = this.sessions.get(node.id);
|
|
274
|
+
if (!active) {
|
|
275
|
+
const handle = this.openExecution({ executor: { ...executor, cwd: node.workspace }, inputFiles: node.inputFiles, resultsRoot: this.results.root }, (kind, data, output) => this.record(id, nodeId, kind, data, output), () => {
|
|
276
|
+
this.running(id);
|
|
277
|
+
this.authorized(task, node.definition.executorId);
|
|
278
|
+
checkWorkspace(node.workspace);
|
|
279
|
+
if (task.input.expiresAt && Date.now() >= Date.parse(task.input.expiresAt))
|
|
280
|
+
throw new Error("Deadline reached before ACP start");
|
|
281
|
+
});
|
|
282
|
+
active = { runId: id, handle, closing: false,
|
|
283
|
+
timer: setTimeout(() => {
|
|
284
|
+
const waiting = this.store.run(id).status === "waiting_input";
|
|
285
|
+
this.stopActive(id, waiting ? "interrupted" : "timed_out", waiting
|
|
286
|
+
? "ACP session lifetime expired while waiting; no replay" : "ACP node session timed out");
|
|
287
|
+
}, node.definition.maxRunSeconds * 1000) };
|
|
288
|
+
this.sessions.set(node.id, active);
|
|
289
|
+
const owned = active;
|
|
290
|
+
void handle.closed.then(() => {
|
|
291
|
+
if (!owned.closing && !this.closed && this.store.healthy)
|
|
292
|
+
this.stopActive(id, "interrupted", "ACP session disconnected; external effects unknown");
|
|
293
|
+
}).catch(this.fatal);
|
|
294
|
+
}
|
|
295
|
+
const result = await active.handle.prompt(text);
|
|
296
|
+
const current = this.running(id), saved = this.node(current, nodeId);
|
|
297
|
+
saved.turns.at(-1).stopReason = result.stopReason;
|
|
298
|
+
// An ended turn is only material for the coordinator to review, never a deliverable.
|
|
299
|
+
saved.status = "awaiting_review";
|
|
300
|
+
this.store.commit(undefined, current);
|
|
301
|
+
if (result.stopReason !== "end_turn")
|
|
302
|
+
this.stopActive(id, "failed", "ACP stopped: " + result.stopReason);
|
|
303
|
+
return this.node(this.store.run(id), nodeId);
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
if (error instanceof StorageError) {
|
|
307
|
+
this.fatal(error);
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
if (this.store.healthy)
|
|
311
|
+
this.stopActive(id, "interrupted", errorText(error));
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
record(id, nodeId, kind, data, text) {
|
|
316
|
+
const run = this.running(id), node = this.node(run, nodeId);
|
|
317
|
+
if (node.status !== "running")
|
|
318
|
+
throw new Error("Unexpected ACP output outside a turn");
|
|
319
|
+
if (++node.eventCount > EVENT_LIMIT || Buffer.byteLength(node.turns.map(turn => turn.output).join("") + text) > OUTPUT_LIMIT)
|
|
320
|
+
throw new Error("Node output/event limit exceeded");
|
|
321
|
+
node.turns.at(-1).output += text;
|
|
322
|
+
if (kind === "session" && typeof data === "string")
|
|
323
|
+
node.sessionId = data;
|
|
324
|
+
this.store.commit(undefined, run);
|
|
325
|
+
}
|
|
326
|
+
async accept(id, nodeId, turn, result, reason) {
|
|
327
|
+
const run = this.running(id), node = this.node(run, nodeId);
|
|
328
|
+
if (node.status !== "awaiting_review" || turn !== node.turns.length || node.turns.at(-1)?.stopReason !== "end_turn")
|
|
329
|
+
throw new Error("Only the latest completed turn can be accepted");
|
|
330
|
+
if (!result.trim() || !node.turns.at(-1).output.includes(result) || !reason.trim() || reason.length > 4000)
|
|
331
|
+
throw new Error("Deliverable must be a nonempty exact excerpt of the executor output, with an acceptance reason");
|
|
332
|
+
// Save the selected delivery, not the conversation or the coordinator's rewritten answer.
|
|
333
|
+
node.resultFile = this.results.save(run.id, node.definition.id, node.id, result);
|
|
334
|
+
node.result = result;
|
|
335
|
+
node.acceptance = reason;
|
|
336
|
+
node.status = "succeeded";
|
|
337
|
+
this.store.commit(undefined, run);
|
|
338
|
+
await this.closeSession(node.id);
|
|
339
|
+
}
|
|
340
|
+
async closeSession(id) {
|
|
341
|
+
const active = this.sessions.get(id);
|
|
342
|
+
if (!active)
|
|
343
|
+
return;
|
|
344
|
+
active.closing = true;
|
|
345
|
+
clearTimeout(active.timer);
|
|
346
|
+
await active.handle.close();
|
|
347
|
+
this.sessions.delete(id);
|
|
348
|
+
}
|
|
349
|
+
end(id, status, reason) {
|
|
350
|
+
const run = this.store.run(id);
|
|
351
|
+
if (!["running", "waiting_input"].includes(run.status))
|
|
352
|
+
return;
|
|
353
|
+
const wasWaiting = run.status === "waiting_input";
|
|
354
|
+
run.status = status;
|
|
355
|
+
run.error = reason.slice(0, 4000);
|
|
356
|
+
run.finishedAt = iso();
|
|
357
|
+
for (const node of run.nodes) {
|
|
358
|
+
if (node.status === "pending")
|
|
359
|
+
node.status = "blocked";
|
|
360
|
+
else if (node.status === "running" || node.status === "awaiting_review")
|
|
361
|
+
node.status = "interrupted";
|
|
362
|
+
if (node.status !== "succeeded")
|
|
363
|
+
node.error = run.error;
|
|
364
|
+
}
|
|
365
|
+
const task = this.store.get(run.taskId);
|
|
366
|
+
if ((status === "interrupted" || wasWaiting) && task.nextAt !== null && task.status !== "cancelled")
|
|
367
|
+
task.status = "paused";
|
|
368
|
+
this.store.commit(task, run);
|
|
369
|
+
}
|
|
370
|
+
stopActive(id, status, reason) {
|
|
371
|
+
try {
|
|
372
|
+
this.end(id, status, reason);
|
|
373
|
+
this.active.get(id)?.controller.abort(new Error(reason));
|
|
374
|
+
for (const [nodeId, session] of this.sessions)
|
|
375
|
+
if (session.runId === id)
|
|
376
|
+
void this.closeSession(nodeId).catch(this.fatal);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
this.fatal(error);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
reply(value, now = Date.now()) {
|
|
383
|
+
this.check();
|
|
384
|
+
const input = replySchema.parse(value);
|
|
385
|
+
this.expireWaiting(this.store.run(input.runId), now);
|
|
386
|
+
const run = this.store.run(input.runId);
|
|
387
|
+
if (!applyReply(run, input, now))
|
|
388
|
+
return run;
|
|
389
|
+
const task = this.store.get(run.taskId);
|
|
390
|
+
for (const node of run.nodes) {
|
|
391
|
+
if (node.status !== "succeeded")
|
|
392
|
+
this.authorized(task, node.definition.executorId);
|
|
393
|
+
if (node.status === "awaiting_review" && (!this.sessions.has(node.id) || this.sessions.get(node.id).closing)) {
|
|
394
|
+
this.stopActive(run.id, "interrupted", "ACP session unavailable at human reply; no replay");
|
|
395
|
+
throw new Error("ACP session unavailable; reconcile external effects first");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
this.store.commit(undefined, run);
|
|
399
|
+
return run;
|
|
400
|
+
}
|
|
401
|
+
expireWaiting(run, now) {
|
|
402
|
+
const failure = waitingFailure(run, now);
|
|
403
|
+
if (failure)
|
|
404
|
+
this.stopActive(run.id, failure.status, failure.reason);
|
|
405
|
+
}
|
|
406
|
+
cancel(id) { this.check(); this.stopActive(id, "cancelled", "Run cancelled; external effects are not rolled back"); return this.store.run(id); }
|
|
407
|
+
recover() {
|
|
408
|
+
this.check();
|
|
409
|
+
for (const run of this.store.activeRounds()) {
|
|
410
|
+
if (run.nodes.some(node => ["running", "awaiting_review"].includes(node.status)))
|
|
411
|
+
this.end(run.id, "interrupted", "Service interrupted with an open ACP session; no replay");
|
|
412
|
+
else
|
|
413
|
+
this.expireWaiting(run, Date.now());
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
start() {
|
|
417
|
+
this.check();
|
|
418
|
+
if (this.timer)
|
|
419
|
+
throw new Error("Task service already started");
|
|
420
|
+
this.recover();
|
|
421
|
+
this.timer = setInterval(() => this.tick(), 500);
|
|
422
|
+
this.tick();
|
|
423
|
+
}
|
|
424
|
+
tick() {
|
|
425
|
+
if (this.closed)
|
|
426
|
+
return;
|
|
427
|
+
try {
|
|
428
|
+
this.trigger();
|
|
429
|
+
for (const run of this.store.activeRounds()) {
|
|
430
|
+
if (run.status === "waiting_input") {
|
|
431
|
+
this.expireWaiting(run, Date.now());
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (this.active.has(run.id) || this.active.size >= 4)
|
|
435
|
+
continue;
|
|
436
|
+
this.launch(run);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
if (this.timer)
|
|
441
|
+
clearInterval(this.timer);
|
|
442
|
+
this.fatal(error);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
launch(run) {
|
|
446
|
+
const task = this.store.get(run.taskId);
|
|
447
|
+
if (!this.coordinate) {
|
|
448
|
+
this.end(run.id, "failed", "Coordinator not configured");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (!run.coordinatorStartedAt && task.input.expiresAt && Date.now() >= Date.parse(task.input.expiresAt)) {
|
|
452
|
+
this.end(run.id, "failed", "Deadline reached before coordinator start");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
run.coordinatorStartedAt ??= iso();
|
|
456
|
+
this.store.commit(undefined, run);
|
|
457
|
+
const remaining = Date.parse(run.coordinatorStartedAt) + run.waitedMs + task.input.maxRunSeconds * 1000 - Date.now();
|
|
458
|
+
if (remaining <= 0) {
|
|
459
|
+
this.end(run.id, "timed_out", "Task run deadline reached");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const input = coordinatorInput(run);
|
|
463
|
+
const stillCurrent = () => {
|
|
464
|
+
const current = this.store.run(run.id);
|
|
465
|
+
return current.status === "running" && coordinatorInput(current).requestId === input.requestId;
|
|
466
|
+
};
|
|
467
|
+
const controller = new AbortController();
|
|
468
|
+
const timer = setTimeout(() => this.stopActive(run.id, "timed_out", "Task run timed out"), remaining);
|
|
469
|
+
// Defer entry so cancellation always finds the controller before external work starts.
|
|
470
|
+
const completion = Promise.resolve().then(() => this.coordinate(run.id, input, this.actions(run.id, input.requestId), controller.signal))
|
|
471
|
+
.then(() => { if (!this.closed && stillCurrent())
|
|
472
|
+
this.end(run.id, "failed", "Coordinator ended without an explicit completion or blocking decision"); }, error => {
|
|
473
|
+
if (error instanceof StorageError)
|
|
474
|
+
throw error;
|
|
475
|
+
if (!this.closed && this.store.healthy && stillCurrent())
|
|
476
|
+
this.end(run.id, "failed", errorText(error));
|
|
477
|
+
}).catch(this.fatal).finally(async () => {
|
|
478
|
+
clearTimeout(timer);
|
|
479
|
+
try {
|
|
480
|
+
if (!this.store.healthy || !["running", "waiting_input"].includes(this.store.run(run.id).status)) {
|
|
481
|
+
await Promise.all([...this.sessions].filter(([, session]) => session.runId === run.id).map(([id]) => this.closeSession(id)));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
this.active.delete(run.id);
|
|
486
|
+
}
|
|
487
|
+
}).catch(this.fatal);
|
|
488
|
+
this.active.set(run.id, { controller, completion, timer });
|
|
489
|
+
}
|
|
490
|
+
close() { return this.closing ??= this.stop(); }
|
|
491
|
+
async stop() {
|
|
492
|
+
if (this.closed)
|
|
493
|
+
return;
|
|
494
|
+
if (this.timer)
|
|
495
|
+
clearInterval(this.timer);
|
|
496
|
+
// Waiting runs may own sessions without an active coordinator.
|
|
497
|
+
for (const id of new Set([...this.sessions.values()].map(session => session.runId))) {
|
|
498
|
+
this.end(id, "interrupted", "Service stopped with an open ACP session");
|
|
499
|
+
}
|
|
500
|
+
for (const [, active] of this.active) {
|
|
501
|
+
active.controller.abort(new Error("Service stopping"));
|
|
502
|
+
}
|
|
503
|
+
this.closed = true;
|
|
504
|
+
await Promise.all([...this.sessions.keys()].map(id => this.closeSession(id)));
|
|
505
|
+
await Promise.all([...this.active.values()].map(active => active.completion));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
//# sourceMappingURL=tasks.js.map
|
package/dist/version.js
ADDED
package/dist/web/api.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
let token = "";
|
|
2
|
+
|
|
3
|
+
export function disconnect() { token = ""; }
|
|
4
|
+
export async function connect(value) {
|
|
5
|
+
token = value;
|
|
6
|
+
try { return await request("/status"); }
|
|
7
|
+
catch (error) { disconnect(); throw error; }
|
|
8
|
+
}
|
|
9
|
+
export async function request(path, body) {
|
|
10
|
+
let response;
|
|
11
|
+
try {
|
|
12
|
+
response = await fetch("/admin/api" + path, {
|
|
13
|
+
method: body === undefined ? "GET" : "POST",
|
|
14
|
+
headers: { Authorization: "Bearer " + token, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
15
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
16
|
+
credentials: "omit", cache: "no-store", redirect: "error", signal: AbortSignal.timeout(path === "/restart" ? 60_000 : 15_000),
|
|
17
|
+
});
|
|
18
|
+
} catch { throw new Error("请求结果未确认。请刷新核对,不要盲目重复写入。"); }
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
const result = await response.json().catch(() => ({}));
|
|
21
|
+
const error = new Error(response.status === 401 ? "管理员令牌无效,请重新连接。" : result.error ?? "请求被拒绝:" + response.status);
|
|
22
|
+
error.status = response.status;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return (await response.json()).data;
|
|
26
|
+
}
|