@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.
@@ -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
- prompt: z.string().trim().min(1).max(64_000),
6
- dependsOn: nodeData.shape.dependsOn.default([]), maxRunSeconds: nodeData.shape.maxRunSeconds.default(600),
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({ answer: z.string().trim().min(1).max(16000) });
9
- export const taskIdSchema = z.object({ id: z.string().uuid() }).strict();
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), limit: z.number().int().min(1).max(100).default(20),
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({ taskId: z.string().uuid().optional() });
14
- export const taskUpdateSchema = taskUpdateData;
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
- name: z.string().trim().min(1).max(200),
17
- prompt: z.string().trim().min(1).max(64_000).describe("Overall goal; the caller supplies the fixed DAG."),
18
- nodes: z.array(nodeSchema).min(1).max(20).describe("Fixed DAG with a ready local ACP executorId on every node."),
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({ type: z.literal("once"), at: requestTimestamp }).strict(),
22
- z.object({ type: z.literal("interval"), startAt: requestTimestamp, seconds: z.number().int().min(60).max(31_536_000) }).strict(),
23
- ]),
24
- expiresAt: requestTimestamp.optional(),
25
- maxRunSeconds: taskData.shape.maxRunSeconds.default(1800), maxSteps: taskData.shape.maxSteps.default(40),
26
- maxNodeTurns: taskData.shape.maxNodeTurns.default(3), inputWaitSeconds: taskData.shape.inputWaitSeconds.default(86400),
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
@@ -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) {
@@ -1,3 +1,6 @@
1
1
  export const OUTPUT_LIMIT = 262_144;
2
2
  export const EVENT_LIMIT = 2000;
3
+ export const MAX_ACTIVE_RUNS = 4;
4
+ export const MAX_TASKS = 1000;
5
+ export const MAX_NODE_SESSIONS = 8;
3
6
  //# sourceMappingURL=limits.js.map
@@ -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 Error("Task definition service closed"); }
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 Error("Coordinator is not configured");
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 Error("workspace is required without a local MCP launch directory");
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 Error("requestKey already used with different parameters");
35
+ throw new TaskError("conflict", "requestKey already used with different parameters");
34
36
  return existing;
35
37
  }
36
- if (all.length >= 1000)
37
- throw new Error("Task definition limit reached");
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 Error("Schedule must start in the future");
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 Error("Deadline must follow first trigger");
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 Error("Task has no future schedule");
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 Error("Task has uncertain external outcomes; future triggers require reconciliation");
73
+ throw new TaskError("precondition", "Task has uncertain external outcomes; future triggers require reconciliation");
72
74
  task = resumeSchedule(task, now);
73
75
  }
74
76
  }
@@ -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 Error("Invalid or duplicate plan nodes");
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 Error("Cyclic dependency: " + id);
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 Error("Unknown dependency: " + id);
14
+ throw new TaskError("validation", "Unknown dependency: " + id);
14
15
  if (new Set(node.dependsOn).size !== node.dependsOn.length)
15
- throw new Error("Duplicate dependency");
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 Error("Task run is not running");
6
+ throw new TaskError("precondition", "Task run is not running");
6
7
  if (run.nodes.some(node => node.status === "running"))
7
- throw new Error("Wait for all ACP turns before asking for input");
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 Error("Unknown question node");
10
+ throw new TaskError("not_found", "Unknown question node");
10
11
  if (run.questions.length >= 20)
11
- throw new Error("Question limit reached");
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 Error("Unknown question");
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 Error("Reply requestKey already used with different parameters");
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 Error("Question already answered or stale");
44
+ throw new TaskError("conflict", "Question already answered or stale");
44
45
  if (run.status !== "waiting_input")
45
- throw new Error("Task run is not waiting for input: " + run.status);
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 Error(failure.reason);
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";
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { EVENT_LIMIT, OUTPUT_LIMIT } from "../shared/limits.js";
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 Error("Task run service closed"); }
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 Error("Task run is not running: " + run.status);
34
+ throw new TaskError("precondition", "Task run is not running: " + run.status);
34
35
  if (requestId !== undefined && coordinatorInput(run).requestId !== requestId)
35
- throw new Error("Stale coordinator input");
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 Error("Unknown plan node");
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 Error("Step budget exhausted");
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 Error("Every planned node must have an accepted deliverable");
97
+ throw new TaskError("precondition", "Every planned node must have an accepted deliverable");
97
98
  if (!summary.trim() || summary.length > 16000)
98
- throw new Error("Invalid final summary");
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 Error("Node cannot start or continue: " + node.status);
109
+ throw new TaskError("precondition", "Node cannot start or continue: " + node.status);
109
110
  if (node.turns.length >= task.input.maxNodeTurns)
110
- throw new Error("Node turn budget exhausted");
111
+ throw new TaskError("precondition", "Node turn budget exhausted");
111
112
  if (message !== undefined && (!message.trim() || message.length > 64000))
112
- throw new Error("Invalid follow-up");
113
+ throw new TaskError("validation", "Invalid follow-up");
113
114
  if (node.status === "pending" && message !== undefined)
114
- throw new Error("First turn uses the committed plan prompt");
115
+ throw new TaskError("validation", "First turn uses the committed plan prompt");
115
116
  if (node.status === "awaiting_review" && !message)
116
- throw new Error("Follow-up requires a message");
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 Error("Dependencies have no accepted deliverable");
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 Error("Deadline reached before node start");
124
- if (sessions.size >= 8)
125
- throw new Error("ACP session capacity reached; accept existing results first");
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 Error("ACP session unavailable");
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 Error("Dispatch is no longer active");
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 Error("Deadline reached before ACP start");
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 Error("Unexpected ACP output outside a turn");
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 Error("Node output/event limit exceeded");
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 Error("Turn is no longer active");
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 Error("Only the latest completed turn can be accepted");
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 Error("Deliverable must be a nonempty exact excerpt of the executor output, with an acceptance reason");
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 Error("ACP session unavailable; reconcile external effects first");
228
+ throw new TaskError("precondition", "ACP session unavailable; reconcile external effects first");
228
229
  }
229
230
  }
230
231
  this.store.commitRun(run);
@@ -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 Error("Task has no future schedule");
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: z.enum(["active", "paused", "cancelled", "completed", "expired"]),
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: z.enum(["running", "succeeded", "failed", "interrupted", "skipped", "cancelled", "timed_out", "waiting_input"]),
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: z.enum(["pending", "running", "awaiting_review", "succeeded", "failed", "blocked", "interrupted"]),
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 Error("Task not found");
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 Error("Task run not found");
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. */
@@ -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 Error("Run supervisor closed"); }
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(), storageHealthy: this.runs.healthy };
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 Error("Recovery requires no active resources");
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 Error("Run supervisor already started");
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 >= 4)
151
+ if (this.active.has(run.id) || this.active.size >= MAX_ACTIVE_RUNS)
149
152
  continue;
150
153
  this.launch(run.id);
151
154
  }
@@ -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
- `agent-task executor list` 查看当前发现和准备结果;`executor show codex` 查看单个执行端。安装新的原生 CLI 后,运行 `executor refresh` 重新检测并在后台准备缺少的 ACP 适配器。该操作无需重启。准备失败时先检查报告的原因,修正后显式刷新或升级。
48
-
49
- 已准备的适配器不会随普通启动或刷新自动升级。需要更新时运行 `agent-task executor upgrade codex --yes`,再用 `executor list` 检查结果。升级期间暂停向该执行端派发新工作;它不修改已有任务指派。OpenCode 使用原生 ACP 命令,通过原生 CLI 的安装渠道更新。
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
- 就绪状态只表示启动依赖已准备。原生登录和权限仍由执行端管理;首次准备、版本来源及运行边界见[部署说明](deployment.md)。
56
+ 发现、安装复用、升级影响和原生认证统一见[部署说明](deployment.md#执行端发现与准备)。就绪不证明工具授权或任务成功;PATH 变化也不能仅靠刷新生效。
52
57
 
53
58
  ## 凭据与任务
54
59
 
@@ -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`,再查询状态;新版本发布成功前保留原安装,活动 ACP 进程不因升级被重启。高级启停设置及 Web/CLI 操作见 [CLI 说明](cli.md#执行端发现与更新)。
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
- 打开 `status` 返回地址同源的 `/admin/`,用本地编辑器读取当前 `dataDir/environment.json` 中的 `AGENT_TASK_ADMIN_TOKEN`,在连接表单输入。不要把 token 放入 URL、命令参数、Git 或截图。CLI 从私有文件读取管理员令牌。两端使用同一管理服务,见[控制台说明](web-console.md)和 [CLI 说明](cli.md)。
85
+ Web 访问 `status` 返回地址同源的 `/admin/`,连接与令牌保护见[控制台说明](web-console.md#启用与连接);命令行管理见 [CLI 说明](cli.md)。两端使用同一管理服务。
86
86
 
87
87
  修改数据目录后,下次启动只准备目标配置和私有环境,并切换定位文件;不复制任务、会话、结果或日志,不删除旧目录。目标已有不同启动文件时拒绝覆盖,须停服核对并显式准备目标。新目录没有旧任务,目录切换不是恢复或重放许可。
88
88