@realqiyan/agent-task 0.1.0-rc.1 → 0.1.0-rc.3
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/README.md +5 -26
- package/dist/executors/registry.js +9 -9
- package/dist/infrastructure/workspace.js +4 -3
- package/dist/protocol/mcp-contract.js +227 -0
- package/dist/protocol/mcp.js +99 -32
- package/dist/protocol/stdio.js +2 -1
- package/dist/protocol/task-contract.js +45 -18
- package/dist/shared/errors.js +15 -0
- package/dist/shared/limits.js +3 -0
- package/dist/tasks/definitions.js +12 -10
- package/dist/tasks/graph.js +5 -4
- package/dist/tasks/human-input.js +10 -9
- package/dist/tasks/runs.js +29 -28
- package/dist/tasks/schedule.js +2 -1
- package/dist/tasks/storage/records.js +4 -4
- package/dist/tasks/storage/store.js +3 -3
- package/dist/tasks/supervisor.js +9 -6
- package/dist/tasks/validation.js +3 -0
- package/docs/cli.md +9 -4
- package/docs/deployment.md +2 -2
- package/docs/release.md +29 -10
- package/docs/technical-design.md +4 -6
- package/docs/validation.md +39 -136
- package/docs/web-console.md +1 -4
- package/package.json +1 -1
|
@@ -1,28 +1,55 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { requestTimestamp } from "../shared/validation.js";
|
|
3
|
-
import { nodeData, replyData, taskData, taskUpdateData } from "../tasks/validation.js";
|
|
3
|
+
import { nodeData, replyData, runStatusSchema, taskData, taskUpdateData } from "../tasks/validation.js";
|
|
4
4
|
export const nodeSchema = nodeData.extend({
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
id: nodeData.shape.id.describe("Unique caller-defined ID in this DAG, referenced by dependsOn; not a run UUID."),
|
|
6
|
+
prompt: z.string().trim().min(1).max(64_000).describe("Self-contained work and acceptance criteria for this executor. The task-level prompt is not copied here. Plain text/Markdown delivery is valid unless a format is required."),
|
|
7
|
+
executorId: nodeData.shape.executorId.describe("ID from executor_list with state=ready; fixed at creation, with no automatic fallback."),
|
|
8
|
+
dependsOn: nodeData.shape.dependsOn.default([]).describe("Direct upstream node IDs; omit for none. Must exist, with no duplicates, self-references or cycles. Only their accepted deliveries unlock this node and are supplied as file paths, not full transcripts."),
|
|
9
|
+
maxRunSeconds: nodeData.shape.maxRunSeconds.default(600).describe("Total ACP session lifetime in seconds from opening, including execution, review, clarification and human-input waits; does not reset each turn."),
|
|
7
10
|
});
|
|
8
|
-
export const replySchema = replyData.extend({
|
|
9
|
-
|
|
11
|
+
export const replySchema = replyData.extend({
|
|
12
|
+
runId: replyData.shape.runId.describe("UUID of the run that owns the question; not a task or node ID."),
|
|
13
|
+
questionId: replyData.shape.questionId.describe("Latest unanswered questions[].id from task_run_list/get. Reply before expiresAt and any non-null sessionExpiresAt."),
|
|
14
|
+
requestKey: replyData.shape.requestKey.describe("Caller-chosen reply key, scoped to this run. Use a new key per answer; retry with the same key, questionId and trimmed answer to return the current run without resuming twice, even after completion. Conflicting reuse fails."),
|
|
15
|
+
answer: z.string().trim().min(1).max(16000).describe("User-provided facts answering this question, not invented assumptions or permission grants. Trimmed before storage and duplicate comparison."),
|
|
16
|
+
});
|
|
17
|
+
export const taskIdSchema = z.object({ id: z.string().uuid().describe("Persisted task UUID returned by task_create or task_list; not a run ID.") }).strict();
|
|
18
|
+
export const runIdSchema = z.object({ id: z.string().uuid().describe("Persisted task-run UUID returned by task_run_list or task_run_get; not a task or DAG node ID.") }).strict();
|
|
10
19
|
export const taskListSchema = z.object({
|
|
11
|
-
after: z.number().int().nonnegative().default(0),
|
|
20
|
+
after: z.number().int().nonnegative().default(0).describe("Zero-based offset in the matching records, oldest first; not an ID or timestamp. Use next with unchanged filters to continue a scan. Restart at 0 to refresh, especially with status filters; pages are not a snapshot."),
|
|
21
|
+
limit: z.number().int().min(1).max(100).default(20).describe("Maximum records per page."),
|
|
12
22
|
}).strict();
|
|
13
|
-
export const taskRunListSchema = taskListSchema.extend({
|
|
14
|
-
|
|
23
|
+
export const taskRunListSchema = taskListSchema.extend({
|
|
24
|
+
taskId: z.string().uuid().optional().describe("Filter runs by task UUID. Omit to list runs across all tasks; unknown task IDs yield an empty page."),
|
|
25
|
+
status: runStatusSchema.optional().describe("Filter by run state; omit for all outcomes. waiting_input finds questions; running also includes queued runs, not just active ACP work."),
|
|
26
|
+
});
|
|
27
|
+
export const taskUpdateSchema = taskUpdateData.extend({
|
|
28
|
+
id: taskIdSchema.shape.id,
|
|
29
|
+
action: taskUpdateData.shape.action.describe("pause keeps the next trigger but suspends it; resume re-enables it without catch-up (past interval slots advance; past now/once slots become completed); cancel permanently removes future triggers. pause/resume require a remaining trigger. resume fails if any run has an interrupted node."),
|
|
30
|
+
});
|
|
15
31
|
export const taskSchema = taskData.extend({
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
32
|
+
requestKey: taskData.shape.requestKey.describe("Caller-chosen creation key, scoped to the canonical workspace. Keep it stable for retries: identical normalized parameters return the existing task, including cancelled/completed tasks; changes conflict. A new key creates separate work, not a retry."),
|
|
33
|
+
name: z.string().trim().min(1).max(200).describe("Short display name; not an identifier or deduplication key."),
|
|
34
|
+
prompt: z.string().trim().min(1).max(64_000).describe("Overall goal and final delivery criteria for the coordinator. Put each executor's instructions in its node prompt; this does not replace the DAG."),
|
|
35
|
+
nodes: z.array(nodeSchema).min(1).max(20).describe("Complete fixed DAG; use one node for a single-step task. Only dependsOn imposes order, not array position; independent nodes may run concurrently."),
|
|
36
|
+
workspace: taskData.shape.workspace.describe("Existing absolute directory on the service machine, canonicalized and fixed for every run. Omit only via local stdio, which uses its launch directory, not necessarily the host's current project. Required over direct HTTP; not a sandbox or permission grant."),
|
|
19
37
|
schedule: z.discriminatedUnion("type", [
|
|
20
|
-
z.object({ type: z.literal("now") }).strict(),
|
|
21
|
-
z.object({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
z.object({ type: z.literal("now").describe("One slot eligible immediately; the scheduler creates and starts the run asynchronously.") }).strict(),
|
|
39
|
+
z.object({
|
|
40
|
+
type: z.literal("once").describe("One slot at the specified future time."),
|
|
41
|
+
at: requestTimestamp.describe("Future ISO 8601 trigger time with UTC Z or numeric offset; normalized to UTC."),
|
|
42
|
+
}).strict(),
|
|
43
|
+
z.object({
|
|
44
|
+
type: z.literal("interval").describe("Repeating slots anchored to startAt, not to completion of the previous run."),
|
|
45
|
+
startAt: requestTimestamp.describe("First trigger, in the future, as ISO 8601 with UTC Z or numeric offset."),
|
|
46
|
+
seconds: z.number().int().min(60).max(31_536_000).describe("Fixed elapsed seconds between slots; not a calendar/timezone schedule."),
|
|
47
|
+
}).strict(),
|
|
48
|
+
]).describe("When runs become eligible, not guaranteed start times. Slots over 30 seconds late or overlapping a running/waiting run of this task are skipped, not queued for catch-up."),
|
|
49
|
+
expiresAt: requestTimestamp.optional().describe("Cutoff for new runs and previously unstarted nodes, not a completion timeout. Must follow the first trigger. Existing ACP sessions may continue. ISO 8601 with timezone; omit for no cutoff."),
|
|
50
|
+
maxRunSeconds: taskData.shape.maxRunSeconds.default(1800).describe("Run execution budget in seconds from coordinator start, excluding human-input waits; includes ACP execution. Separate from expiresAt and each node's session lifetime."),
|
|
51
|
+
maxSteps: taskData.shape.maxSteps.default(40).describe("Maximum coordinator tool calls per run, including inspection, delivery acceptance and finalization; not executor-internal tool calls."),
|
|
52
|
+
maxNodeTurns: taskData.shape.maxNodeTurns.default(3).describe("Maximum ACP turns per node per run: initial work plus clarifications in the same session, not automatic retries."),
|
|
53
|
+
inputWaitSeconds: taskData.shape.inputWaitSeconds.default(86400).describe("Seconds allowed for each user answer. With retained ACP sessions, waiting is also capped at 300 seconds and their remaining lifetime. Follow the earlier of the question's expiresAt and non-null sessionExpiresAt."),
|
|
27
54
|
});
|
|
28
55
|
//# sourceMappingURL=task-contract.js.map
|
package/dist/shared/errors.js
CHANGED
|
@@ -3,6 +3,21 @@ export function errorText(error) {
|
|
|
3
3
|
}
|
|
4
4
|
export class StorageError extends Error {
|
|
5
5
|
}
|
|
6
|
+
/** A business failure with a stable class; the message stays the operator-facing explanation. */
|
|
7
|
+
export class TaskError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function errorCode(error) {
|
|
15
|
+
if (error instanceof TaskError)
|
|
16
|
+
return error.code;
|
|
17
|
+
if (error instanceof StorageError)
|
|
18
|
+
return "unavailable";
|
|
19
|
+
return "internal";
|
|
20
|
+
}
|
|
6
21
|
export class FileCommitError extends Error {
|
|
7
22
|
uncertain;
|
|
8
23
|
constructor(uncertain) {
|
package/dist/shared/limits.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { TaskError } from "../shared/errors.js";
|
|
3
|
+
import { MAX_TASKS } from "../shared/limits.js";
|
|
2
4
|
import { iso } from "../shared/time.js";
|
|
3
5
|
import { validateGraph } from "./graph.js";
|
|
4
6
|
import { hasUncertainOutcome, initialTime, resumeSchedule } from "./schedule.js";
|
|
@@ -17,31 +19,31 @@ export class TaskDefinitions {
|
|
|
17
19
|
this.coordinatorConfigured = coordinatorConfigured;
|
|
18
20
|
}
|
|
19
21
|
check() { if (this.closed)
|
|
20
|
-
throw new
|
|
22
|
+
throw new TaskError("unavailable", "Task definition service closed"); }
|
|
21
23
|
create(value, now = Date.now()) {
|
|
22
24
|
this.check();
|
|
23
25
|
if (!this.coordinatorConfigured)
|
|
24
|
-
throw new
|
|
26
|
+
throw new TaskError("precondition", "Coordinator is not configured");
|
|
25
27
|
const input = taskData.parse(value), all = this.store.definitions();
|
|
26
28
|
if (input.workspace === undefined)
|
|
27
|
-
throw new
|
|
29
|
+
throw new TaskError("validation", "workspace is required without a local MCP launch directory");
|
|
28
30
|
const workspace = this.workspaces.resolve(input.workspace);
|
|
29
31
|
input.workspace = workspace;
|
|
30
32
|
const existing = all.find(item => item.workspace === workspace && item.input.requestKey === input.requestKey);
|
|
31
33
|
if (existing) {
|
|
32
34
|
if (JSON.stringify(existing.input) !== JSON.stringify(input))
|
|
33
|
-
throw new
|
|
35
|
+
throw new TaskError("conflict", "requestKey already used with different parameters");
|
|
34
36
|
return existing;
|
|
35
37
|
}
|
|
36
|
-
if (all.length >=
|
|
37
|
-
throw new
|
|
38
|
+
if (all.length >= MAX_TASKS)
|
|
39
|
+
throw new TaskError("precondition", "Task definition limit reached");
|
|
38
40
|
validateGraph(input.nodes);
|
|
39
41
|
const executors = this.executors.snapshot(input.nodes.map(node => node.executorId));
|
|
40
42
|
const nextAt = initialTime(input.schedule, now);
|
|
41
43
|
if (input.schedule.type !== "now" && nextAt < now)
|
|
42
|
-
throw new
|
|
44
|
+
throw new TaskError("validation", "Schedule must start in the future");
|
|
43
45
|
if (input.expiresAt && Date.parse(input.expiresAt) <= Math.max(now, nextAt))
|
|
44
|
-
throw new
|
|
46
|
+
throw new TaskError("validation", "Deadline must follow first trigger");
|
|
45
47
|
const task = { id: randomUUID(), input, workspace,
|
|
46
48
|
executors, nextAt, status: "active", createdAt: iso(now) };
|
|
47
49
|
this.store.saveDefinition(task);
|
|
@@ -63,12 +65,12 @@ export class TaskDefinitions {
|
|
|
63
65
|
}
|
|
64
66
|
else {
|
|
65
67
|
if (task.nextAt === null || ["cancelled", "expired"].includes(task.status))
|
|
66
|
-
throw new
|
|
68
|
+
throw new TaskError("precondition", "Task has no future schedule");
|
|
67
69
|
if (action === "pause")
|
|
68
70
|
task.status = "paused";
|
|
69
71
|
else {
|
|
70
72
|
if (this.store.rounds(id).some(hasUncertainOutcome))
|
|
71
|
-
throw new
|
|
73
|
+
throw new TaskError("precondition", "Task has uncertain external outcomes; future triggers require reconciliation");
|
|
72
74
|
task = resumeSchedule(task, now);
|
|
73
75
|
}
|
|
74
76
|
}
|
package/dist/tasks/graph.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
+
import { TaskError } from "../shared/errors.js";
|
|
1
2
|
export function validateGraph(nodes) {
|
|
2
3
|
const graph = new Map(nodes.map(node => [node.id, node]));
|
|
3
4
|
if (!nodes.length || nodes.length > 20 || graph.size !== nodes.length)
|
|
4
|
-
throw new
|
|
5
|
+
throw new TaskError("validation", "Invalid or duplicate plan nodes");
|
|
5
6
|
const visited = new Set(), visiting = new Set();
|
|
6
7
|
const visit = (id) => {
|
|
7
8
|
if (visiting.has(id))
|
|
8
|
-
throw new
|
|
9
|
+
throw new TaskError("validation", "Cyclic dependency: " + id);
|
|
9
10
|
if (visited.has(id))
|
|
10
11
|
return;
|
|
11
12
|
const node = graph.get(id);
|
|
12
13
|
if (!node)
|
|
13
|
-
throw new
|
|
14
|
+
throw new TaskError("validation", "Unknown dependency: " + id);
|
|
14
15
|
if (new Set(node.dependsOn).size !== node.dependsOn.length)
|
|
15
|
-
throw new
|
|
16
|
+
throw new TaskError("validation", "Duplicate dependency");
|
|
16
17
|
visiting.add(id);
|
|
17
18
|
node.dependsOn.forEach(visit);
|
|
18
19
|
visiting.delete(id);
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import { TaskError } from "../shared/errors.js";
|
|
1
2
|
import { iso } from "../shared/time.js";
|
|
2
3
|
/** Human facts are persisted business state, not executor permission or retry authorization. */
|
|
3
4
|
export function askInput(run, request, seconds, now, questionId) {
|
|
4
5
|
if (run.status !== "running")
|
|
5
|
-
throw new
|
|
6
|
+
throw new TaskError("precondition", "Task run is not running");
|
|
6
7
|
if (run.nodes.some(node => node.status === "running"))
|
|
7
|
-
throw new
|
|
8
|
+
throw new TaskError("precondition", "Wait for all ACP turns before asking for input");
|
|
8
9
|
if (request.nodeId && !run.nodes.some(node => node.definition.id === request.nodeId))
|
|
9
|
-
throw new
|
|
10
|
+
throw new TaskError("not_found", "Unknown question node");
|
|
10
11
|
if (run.questions.length >= 20)
|
|
11
|
-
throw new
|
|
12
|
+
throw new TaskError("precondition", "Question limit reached");
|
|
12
13
|
run = structuredClone(run);
|
|
13
14
|
const sessions = run.nodes.filter(node => node.status === "awaiting_review");
|
|
14
15
|
const question = { ...request, id: questionId, createdAt: iso(now), expiresAt: iso(now + seconds * 1000),
|
|
@@ -32,20 +33,20 @@ export function applyReply(run, input, now) {
|
|
|
32
33
|
run = structuredClone(run);
|
|
33
34
|
const question = run.questions.find(value => value.id === input.questionId);
|
|
34
35
|
if (!question)
|
|
35
|
-
throw new
|
|
36
|
+
throw new TaskError("not_found", "Unknown question");
|
|
36
37
|
const used = run.questions.find(value => value.reply?.requestKey === input.requestKey);
|
|
37
38
|
if (used) {
|
|
38
39
|
if (used.id !== question.id || used.reply.answer !== input.answer)
|
|
39
|
-
throw new
|
|
40
|
+
throw new TaskError("conflict", "Reply requestKey already used with different parameters");
|
|
40
41
|
return undefined;
|
|
41
42
|
}
|
|
42
43
|
if (question.reply || run.questions.at(-1)?.id !== question.id)
|
|
43
|
-
throw new
|
|
44
|
+
throw new TaskError("conflict", "Question already answered or stale");
|
|
44
45
|
if (run.status !== "waiting_input")
|
|
45
|
-
throw new
|
|
46
|
+
throw new TaskError("precondition", "Task run is not waiting for input: " + run.status);
|
|
46
47
|
const failure = waitingFailure(run, now);
|
|
47
48
|
if (failure)
|
|
48
|
-
throw new
|
|
49
|
+
throw new TaskError("precondition", failure.reason);
|
|
49
50
|
question.reply = { requestKey: input.requestKey, answer: input.answer, receivedAt: iso(now) };
|
|
50
51
|
run.waitedMs += Math.max(0, now - Date.parse(question.createdAt));
|
|
51
52
|
run.status = "running";
|
package/dist/tasks/runs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { TaskError } from "../shared/errors.js";
|
|
3
|
+
import { EVENT_LIMIT, MAX_NODE_SESSIONS, OUTPUT_LIMIT } from "../shared/limits.js";
|
|
3
4
|
import { iso } from "../shared/time.js";
|
|
4
5
|
import { applyReply, askInput, coordinatorInput, waitingFailure } from "./human-input.js";
|
|
5
6
|
import { dueTrigger, hasUncertainOutcome } from "./schedule.js";
|
|
@@ -19,26 +20,26 @@ export class TaskRuns {
|
|
|
19
20
|
}
|
|
20
21
|
get healthy() { return !this.closed && this.store.healthy; }
|
|
21
22
|
check() { if (this.closed)
|
|
22
|
-
throw new
|
|
23
|
+
throw new TaskError("unavailable", "Task run service closed"); }
|
|
23
24
|
get(id) { this.check(); return this.store.run(id); }
|
|
24
|
-
list(taskId, after = 0, limit = 20) {
|
|
25
|
+
list(taskId, after = 0, limit = 20, status) {
|
|
25
26
|
this.check();
|
|
26
|
-
const all = this.store.rounds(taskId);
|
|
27
|
+
const all = this.store.rounds(taskId).filter(run => status === undefined || run.status === status);
|
|
27
28
|
return { items: all.slice(after, after + limit), next: all.length > after + limit ? after + limit : null };
|
|
28
29
|
}
|
|
29
30
|
active() { this.check(); return this.store.activeRounds(); }
|
|
30
31
|
current(id, requestId) {
|
|
31
32
|
const run = this.get(id);
|
|
32
33
|
if (run.status !== "running")
|
|
33
|
-
throw new
|
|
34
|
+
throw new TaskError("precondition", "Task run is not running: " + run.status);
|
|
34
35
|
if (requestId !== undefined && coordinatorInput(run).requestId !== requestId)
|
|
35
|
-
throw new
|
|
36
|
+
throw new TaskError("conflict", "Stale coordinator input");
|
|
36
37
|
return run;
|
|
37
38
|
}
|
|
38
39
|
node(run, id) {
|
|
39
40
|
const node = run.nodes.find(node => node.definition.id === id);
|
|
40
41
|
if (!node)
|
|
41
|
-
throw new
|
|
42
|
+
throw new TaskError("not_found", "Unknown plan node");
|
|
42
43
|
return node;
|
|
43
44
|
}
|
|
44
45
|
inspect(id, requestId) {
|
|
@@ -79,7 +80,7 @@ export class TaskRuns {
|
|
|
79
80
|
const run = this.current(id), task = this.store.get(run.taskId);
|
|
80
81
|
if (run.steps >= task.input.maxSteps) {
|
|
81
82
|
this.end(id, "failed", "Coordinator step budget exhausted");
|
|
82
|
-
throw new
|
|
83
|
+
throw new TaskError("precondition", "Step budget exhausted");
|
|
83
84
|
}
|
|
84
85
|
run.steps++;
|
|
85
86
|
this.store.commitRun(run);
|
|
@@ -93,9 +94,9 @@ export class TaskRuns {
|
|
|
93
94
|
finish(id, summary) {
|
|
94
95
|
const run = this.current(id);
|
|
95
96
|
if (!run.nodes.length || run.nodes.some(node => node.status !== "succeeded"))
|
|
96
|
-
throw new
|
|
97
|
+
throw new TaskError("precondition", "Every planned node must have an accepted deliverable");
|
|
97
98
|
if (!summary.trim() || summary.length > 16000)
|
|
98
|
-
throw new
|
|
99
|
+
throw new TaskError("validation", "Invalid final summary");
|
|
99
100
|
run.summary = summary;
|
|
100
101
|
run.status = "succeeded";
|
|
101
102
|
run.finishedAt = iso();
|
|
@@ -105,31 +106,31 @@ export class TaskRuns {
|
|
|
105
106
|
const run = this.current(id), task = this.store.get(run.taskId), node = this.node(run, nodeId);
|
|
106
107
|
const executor = this.executors.authorize(task.executors[node.definition.executorId]);
|
|
107
108
|
if (!["pending", "awaiting_review"].includes(node.status))
|
|
108
|
-
throw new
|
|
109
|
+
throw new TaskError("precondition", "Node cannot start or continue: " + node.status);
|
|
109
110
|
if (node.turns.length >= task.input.maxNodeTurns)
|
|
110
|
-
throw new
|
|
111
|
+
throw new TaskError("precondition", "Node turn budget exhausted");
|
|
111
112
|
if (message !== undefined && (!message.trim() || message.length > 64000))
|
|
112
|
-
throw new
|
|
113
|
+
throw new TaskError("validation", "Invalid follow-up");
|
|
113
114
|
if (node.status === "pending" && message !== undefined)
|
|
114
|
-
throw new
|
|
115
|
+
throw new TaskError("validation", "First turn uses the committed plan prompt");
|
|
115
116
|
if (node.status === "awaiting_review" && !message)
|
|
116
|
-
throw new
|
|
117
|
+
throw new TaskError("validation", "Follow-up requires a message");
|
|
117
118
|
const parents = node.definition.dependsOn.map(parent => this.node(run, parent));
|
|
118
119
|
if (parents.some(parent => parent.status !== "succeeded" || !parent.resultFile))
|
|
119
|
-
throw new
|
|
120
|
+
throw new TaskError("precondition", "Dependencies have no accepted deliverable");
|
|
120
121
|
const opening = node.status === "pending";
|
|
121
122
|
if (opening) {
|
|
122
123
|
if (task.input.expiresAt && Date.now() >= Date.parse(task.input.expiresAt))
|
|
123
|
-
throw new
|
|
124
|
-
if (sessions.size >=
|
|
125
|
-
throw new
|
|
124
|
+
throw new TaskError("precondition", "Deadline reached before node start");
|
|
125
|
+
if (sessions.size >= MAX_NODE_SESSIONS)
|
|
126
|
+
throw new TaskError("precondition", "ACP session capacity reached; accept existing results first");
|
|
126
127
|
node.workspace = task.workspace;
|
|
127
128
|
this.workspaces.check(node.workspace);
|
|
128
129
|
node.inputFiles = parents.map(parent => parent.resultFile);
|
|
129
130
|
}
|
|
130
131
|
else if (!sessions.has(node.id)) {
|
|
131
132
|
this.end(id, "interrupted", "ACP session unavailable; no replay");
|
|
132
|
-
throw new
|
|
133
|
+
throw new TaskError("precondition", "ACP session unavailable");
|
|
133
134
|
}
|
|
134
135
|
for (const path of node.inputFiles)
|
|
135
136
|
this.results.readable(path);
|
|
@@ -147,18 +148,18 @@ export class TaskRuns {
|
|
|
147
148
|
beforeStart(id, nodeId) {
|
|
148
149
|
const run = this.current(id), task = this.store.get(run.taskId), node = this.node(run, nodeId);
|
|
149
150
|
if (node.status !== "running")
|
|
150
|
-
throw new
|
|
151
|
+
throw new TaskError("conflict", "Dispatch is no longer active");
|
|
151
152
|
this.executors.authorize(task.executors[node.definition.executorId]);
|
|
152
153
|
this.workspaces.check(node.workspace);
|
|
153
154
|
if (task.input.expiresAt && Date.now() >= Date.parse(task.input.expiresAt))
|
|
154
|
-
throw new
|
|
155
|
+
throw new TaskError("precondition", "Deadline reached before ACP start");
|
|
155
156
|
}
|
|
156
157
|
record(id, nodeId, kind, data, text) {
|
|
157
158
|
const run = this.current(id), node = this.node(run, nodeId);
|
|
158
159
|
if (node.status !== "running")
|
|
159
|
-
throw new
|
|
160
|
+
throw new TaskError("precondition", "Unexpected ACP output outside a turn");
|
|
160
161
|
if (++node.eventCount > EVENT_LIMIT || Buffer.byteLength(node.turns.map(turn => turn.output).join("") + text) > OUTPUT_LIMIT)
|
|
161
|
-
throw new
|
|
162
|
+
throw new TaskError("precondition", "Node output/event limit exceeded");
|
|
162
163
|
node.turns.at(-1).output += text;
|
|
163
164
|
if (kind === "session" && typeof data === "string")
|
|
164
165
|
node.sessionId = data;
|
|
@@ -168,7 +169,7 @@ export class TaskRuns {
|
|
|
168
169
|
// Re-read after await: cancellation, deadlines and other node events may have committed meanwhile.
|
|
169
170
|
const run = this.current(id), node = this.node(run, nodeId);
|
|
170
171
|
if (node.status !== "running")
|
|
171
|
-
throw new
|
|
172
|
+
throw new TaskError("precondition", "Turn is no longer active");
|
|
172
173
|
node.turns.at(-1).stopReason = stopReason;
|
|
173
174
|
node.status = "awaiting_review";
|
|
174
175
|
this.store.commitRun(run);
|
|
@@ -179,9 +180,9 @@ export class TaskRuns {
|
|
|
179
180
|
accept(id, nodeId, turn, result, reason) {
|
|
180
181
|
const run = this.current(id), node = this.node(run, nodeId);
|
|
181
182
|
if (node.status !== "awaiting_review" || turn !== node.turns.length || node.turns.at(-1)?.stopReason !== "end_turn")
|
|
182
|
-
throw new
|
|
183
|
+
throw new TaskError("conflict", "Only the latest completed turn can be accepted");
|
|
183
184
|
if (!result.trim() || !node.turns.at(-1).output.includes(result) || !reason.trim() || reason.length > 4000)
|
|
184
|
-
throw new
|
|
185
|
+
throw new TaskError("validation", "Deliverable must be a nonempty exact excerpt of the executor output, with an acceptance reason");
|
|
185
186
|
node.resultFile = this.results.save(run.id, node.definition.id, node.id, result);
|
|
186
187
|
node.result = result;
|
|
187
188
|
node.acceptance = reason;
|
|
@@ -224,7 +225,7 @@ export class TaskRuns {
|
|
|
224
225
|
this.executors.authorize(task.executors[node.definition.executorId]);
|
|
225
226
|
if (node.status === "awaiting_review" && !hasSession(node.id)) {
|
|
226
227
|
this.end(run.id, "interrupted", "ACP session unavailable at human reply; no replay");
|
|
227
|
-
throw new
|
|
228
|
+
throw new TaskError("precondition", "ACP session unavailable; reconcile external effects first");
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
this.store.commitRun(run);
|
package/dist/tasks/schedule.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TaskError } from "../shared/errors.js";
|
|
1
2
|
export function hasUncertainOutcome(run) {
|
|
2
3
|
return run.nodes.some(node => node.status === "interrupted");
|
|
3
4
|
}
|
|
@@ -7,7 +8,7 @@ export function initialTime(schedule, now) {
|
|
|
7
8
|
export function resumeSchedule(task, now) {
|
|
8
9
|
const next = structuredClone(task);
|
|
9
10
|
if (next.nextAt === null || ["cancelled", "expired"].includes(next.status))
|
|
10
|
-
throw new
|
|
11
|
+
throw new TaskError("precondition", "Task has no future schedule");
|
|
11
12
|
next.status = "active";
|
|
12
13
|
if (next.nextAt < now) {
|
|
13
14
|
if (next.input.schedule.type === "interval")
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { executorRecord } from "../../executors/validation.js";
|
|
3
3
|
import { absolutePath, identifier, text, timestamp } from "../../shared/validation.js";
|
|
4
|
-
import { nodeData, questionData, taskData } from "../validation.js";
|
|
4
|
+
import { nodeData, nodeStatusSchema, questionData, runStatusSchema, taskData, taskStatusSchema } from "../validation.js";
|
|
5
5
|
export const definitionSchema = z.object({
|
|
6
6
|
id: z.string().uuid(), input: taskData, workspace: absolutePath, executors: z.record(identifier, executorRecord),
|
|
7
|
-
status:
|
|
7
|
+
status: taskStatusSchema,
|
|
8
8
|
nextAt: z.number().finite().nullable(), createdAt: timestamp,
|
|
9
9
|
}).strict().refine(task => task.input.workspace === task.workspace, "Task workspace must match the canonical request");
|
|
10
10
|
export const roundSchema = z.object({
|
|
11
11
|
id: z.string().uuid(), taskId: z.string().uuid(), scheduledAt: z.number().finite(),
|
|
12
|
-
status:
|
|
12
|
+
status: runStatusSchema,
|
|
13
13
|
createdAt: timestamp, finishedAt: timestamp.nullable(), error: z.string().nullable(), skippedCount: z.number().int().nonnegative(),
|
|
14
14
|
steps: z.number().int().nonnegative(), coordinatorStartedAt: timestamp.nullable(), summary: z.string().nullable(),
|
|
15
15
|
waitedMs: z.number().finite().nonnegative(),
|
|
@@ -19,7 +19,7 @@ export const roundSchema = z.object({
|
|
|
19
19
|
})).max(20),
|
|
20
20
|
nodes: z.array(z.object({
|
|
21
21
|
id: z.string().uuid(), definition: nodeData,
|
|
22
|
-
status:
|
|
22
|
+
status: nodeStatusSchema,
|
|
23
23
|
workspace: absolutePath.nullable(), inputFiles: z.array(absolutePath), resultFile: absolutePath.nullable(), result: z.string().nullable(), acceptance: z.string().nullable(),
|
|
24
24
|
turns: z.array(z.object({ prompt: z.string(), output: z.string(), stopReason: z.string().nullable() }).strict()),
|
|
25
25
|
error: z.string().nullable(), sessionId: z.string().nullable(), startedAt: timestamp.nullable(), eventCount: z.number().int().nonnegative(),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DatabaseSync } from "../../infrastructure/sqlite.js";
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { StorageError } from "../../shared/errors.js";
|
|
4
|
+
import { StorageError, TaskError } from "../../shared/errors.js";
|
|
5
5
|
import { definitionSchema, roundSchema } from "./records.js";
|
|
6
6
|
/** Task definitions and rounds share one transactional store. */
|
|
7
7
|
export class TaskStore {
|
|
@@ -65,14 +65,14 @@ export class TaskStore {
|
|
|
65
65
|
this.check();
|
|
66
66
|
const row = this.db.prepare("SELECT data FROM definitions WHERE id=?").get(id);
|
|
67
67
|
if (!row)
|
|
68
|
-
throw new
|
|
68
|
+
throw new TaskError("not_found", "Task not found");
|
|
69
69
|
return this.decode(definitionSchema, row["data"]);
|
|
70
70
|
}
|
|
71
71
|
run(id) {
|
|
72
72
|
this.check();
|
|
73
73
|
const row = this.db.prepare("SELECT data FROM rounds WHERE id=?").get(id);
|
|
74
74
|
if (!row)
|
|
75
|
-
throw new
|
|
75
|
+
throw new TaskError("not_found", "Task run not found");
|
|
76
76
|
return this.decode(roundSchema, row["data"]);
|
|
77
77
|
}
|
|
78
78
|
/** The service supplies all policy; the store commits a cursor and round atomically. */
|
package/dist/tasks/supervisor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { errorText, StorageError } from "../shared/errors.js";
|
|
1
|
+
import { errorText, StorageError, TaskError } from "../shared/errors.js";
|
|
2
|
+
import { MAX_ACTIVE_RUNS, MAX_NODE_SESSIONS, MAX_TASKS } from "../shared/limits.js";
|
|
2
3
|
import { coordinatorInput } from "./human-input.js";
|
|
3
4
|
import { NodeSessions } from "./sessions.js";
|
|
4
5
|
/** Owns coordinator generations, the calendar clock and NodeSessions. All persisted decisions go through TaskRuns. */
|
|
@@ -19,10 +20,12 @@ export class RunSupervisor {
|
|
|
19
20
|
this.sessions = new NodeSessions(openExecution, fatal);
|
|
20
21
|
}
|
|
21
22
|
check() { if (this.closed)
|
|
22
|
-
throw new
|
|
23
|
+
throw new TaskError("unavailable", "Run supervisor closed"); }
|
|
23
24
|
status() {
|
|
24
25
|
return { running: !this.closed && this.timer !== undefined && this.runs.healthy,
|
|
25
|
-
activeTaskRunIds: [...this.active.keys()], activeNodeRunIds: this.sessions.nodeIds(),
|
|
26
|
+
activeTaskRunIds: [...this.active.keys()], activeNodeRunIds: this.sessions.nodeIds(),
|
|
27
|
+
storageHealthy: this.runs.healthy, coordinatorConfigured: this.coordinate !== undefined,
|
|
28
|
+
limits: { maxActiveRuns: MAX_ACTIVE_RUNS, maxTasks: MAX_TASKS, maxNodeSessions: MAX_NODE_SESSIONS } };
|
|
26
29
|
}
|
|
27
30
|
actions(id, requestId = coordinatorInput(this.runs.get(id)).requestId) {
|
|
28
31
|
this.check();
|
|
@@ -124,13 +127,13 @@ export class RunSupervisor {
|
|
|
124
127
|
recover() {
|
|
125
128
|
this.check();
|
|
126
129
|
if (this.active.size || this.sessions.size)
|
|
127
|
-
throw new
|
|
130
|
+
throw new TaskError("conflict", "Recovery requires no active resources");
|
|
128
131
|
this.runs.recover();
|
|
129
132
|
}
|
|
130
133
|
start() {
|
|
131
134
|
this.check();
|
|
132
135
|
if (this.timer)
|
|
133
|
-
throw new
|
|
136
|
+
throw new TaskError("conflict", "Run supervisor already started");
|
|
134
137
|
this.recover();
|
|
135
138
|
this.timer = setInterval(() => this.tick(), 10_000);
|
|
136
139
|
this.tick();
|
|
@@ -145,7 +148,7 @@ export class RunSupervisor {
|
|
|
145
148
|
this.reconcile(run.id);
|
|
146
149
|
continue;
|
|
147
150
|
}
|
|
148
|
-
if (this.active.has(run.id) || this.active.size >=
|
|
151
|
+
if (this.active.has(run.id) || this.active.size >= MAX_ACTIVE_RUNS)
|
|
149
152
|
continue;
|
|
150
153
|
this.launch(run.id);
|
|
151
154
|
}
|
package/dist/tasks/validation.js
CHANGED
|
@@ -5,6 +5,9 @@ export const scheduleData = z.discriminatedUnion("type", [
|
|
|
5
5
|
z.object({ type: z.literal("once"), at: timestamp }).strict(),
|
|
6
6
|
z.object({ type: z.literal("interval"), startAt: timestamp, seconds: z.number().int().min(60).max(31_536_000) }).strict(),
|
|
7
7
|
]);
|
|
8
|
+
export const taskStatusSchema = z.enum(["active", "paused", "cancelled", "completed", "expired"]);
|
|
9
|
+
export const runStatusSchema = z.enum(["running", "succeeded", "failed", "interrupted", "skipped", "cancelled", "timed_out", "waiting_input"]);
|
|
10
|
+
export const nodeStatusSchema = z.enum(["pending", "running", "awaiting_review", "succeeded", "blocked", "interrupted"]);
|
|
8
11
|
export const nodeData = z.object({
|
|
9
12
|
id: identifier, prompt: text(64_000), executorId: identifier,
|
|
10
13
|
dependsOn: z.array(identifier).max(20), maxRunSeconds: z.number().int().min(1).max(3600),
|
package/docs/cli.md
CHANGED
|
@@ -44,11 +44,16 @@ agent-task config show --running
|
|
|
44
44
|
|
|
45
45
|
## 执行端发现与更新
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
```bash
|
|
48
|
+
agent-task executor list
|
|
49
|
+
agent-task executor show codex
|
|
50
|
+
agent-task executor refresh
|
|
51
|
+
# 仅在需要升级适配器时执行:
|
|
52
|
+
agent-task executor upgrade codex --yes
|
|
53
|
+
agent-task executor list
|
|
54
|
+
```
|
|
50
55
|
|
|
51
|
-
|
|
56
|
+
发现、安装复用、升级影响和原生认证统一见[部署说明](deployment.md#执行端发现与准备)。就绪不证明工具授权或任务成功;PATH 变化也不能仅靠刷新生效。
|
|
52
57
|
|
|
53
58
|
## 凭据与任务
|
|
54
59
|
|
package/docs/deployment.md
CHANGED
|
@@ -54,7 +54,7 @@ agent-task setup --home /absolute/private/agent-task \
|
|
|
54
54
|
|
|
55
55
|
发现原生 CLI 后,后台为缺少 ACP 适配器的执行端安装当时最新版,已有安装直接复用;OpenCode 使用原生 ACP 命令。安装在后台进行,可用 MCP `executor_list`、`agent-task executor list` 或 Web 查看准备结果。任务只能选择就绪执行端。适配器安装失败时保留管理入口和其他就绪执行端,修正原因后显式刷新或升级。包映射和安装实现见 [discovery.ts](../src/integrations/acp/discovery.ts)。
|
|
56
56
|
|
|
57
|
-
适配器保存在安装目录的私有 `adapters/` 中,不修改全局 npm 包或执行 CLI 的原生配置。普通重连不访问 registry 升级已有适配器。显式更新使用 `agent-task executor upgrade codex --yes
|
|
57
|
+
适配器保存在安装目录的私有 `adapters/` 中,不修改全局 npm 包或执行 CLI 的原生配置。普通重连不访问 registry 升级已有适配器。显式更新使用 `agent-task executor upgrade codex --yes`,再查询状态;升级期间暂停向该执行端派发新工作,新版本发布成功前保留原安装,活动 ACP 进程不因升级被重启。重新检测和升级直接作用于当前执行端目录,无需重启,也不保存启动配置。OpenCode 通过原生 CLI 的安装渠道更新。命令入口见 [CLI 说明](cli.md#执行端发现与更新)。
|
|
58
58
|
|
|
59
59
|
后台的原生环境来自启动它的进程,不随不同 MCP 客户端切换。新 CLI 位于已有 PATH 时可用 `executor refresh` 重新检测;PATH 本身变化时先停止后台,再从正确环境连接或启动。原生登录须与后台使用同一 OS 用户及 HOME/配置目录。
|
|
60
60
|
|
|
@@ -82,7 +82,7 @@ MCP 接入不会自动重启一个已存在但不可用的后台。故障时查
|
|
|
82
82
|
|
|
83
83
|
运行配置、私有环境文件、任务和协调会话记录、结果及服务日志位于当前 `dataDir`。安装目录保留 `launch.json` 定位文件、初始化清单、适配器及启动器控制资源。npm 包、项目工作空间和执行端原生配置与数据目录分开。路径实现见 [startup.ts](../src/application/startup.ts)。
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
Web 访问 `status` 返回地址同源的 `/admin/`,连接与令牌保护见[控制台说明](web-console.md#启用与连接);命令行管理见 [CLI 说明](cli.md)。两端使用同一管理服务。
|
|
86
86
|
|
|
87
87
|
修改数据目录后,下次启动只准备目标配置和私有环境,并切换定位文件;不复制任务、会话、结果或日志,不删除旧目录。目标已有不同启动文件时拒绝覆盖,须停服核对并显式准备目标。新目录没有旧任务,目录切换不是恢复或重放许可。
|
|
88
88
|
|