@realqiyan/agent-task 0.0.1-rc.3 → 0.1.0-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.
Files changed (60) hide show
  1. package/CONTEXT.md +17 -0
  2. package/README.md +28 -6
  3. package/bin/agent-task.mjs +18 -6
  4. package/config.example.json +1 -1
  5. package/dist/application/launcher.js +30 -16
  6. package/dist/application/main.js +5 -2
  7. package/dist/application/mcp-entry.js +71 -0
  8. package/dist/application/task-runtime.js +3 -3
  9. package/dist/configuration/contract.js +2 -2
  10. package/dist/configuration/files.js +2 -3
  11. package/dist/configuration/management.js +1 -1
  12. package/dist/configuration/policy.js +3 -8
  13. package/dist/configuration/settings.js +10 -33
  14. package/dist/executors/ports.js +2 -0
  15. package/dist/executors/registry.js +117 -15
  16. package/dist/executors/validation.js +4 -5
  17. package/dist/infrastructure/lock.js +25 -13
  18. package/dist/infrastructure/workspace.js +4 -3
  19. package/dist/integrations/acp/connection.js +5 -5
  20. package/dist/integrations/acp/discovery.js +271 -0
  21. package/dist/integrations/acp/runtime.js +38 -23
  22. package/dist/integrations/acp/worker.js +5 -3
  23. package/dist/integrations/local-service/control.js +36 -11
  24. package/dist/protocol/cli/commands.js +41 -46
  25. package/dist/protocol/http/server.js +8 -0
  26. package/dist/protocol/http/web.js +7 -1
  27. package/dist/protocol/mcp-contract.js +194 -0
  28. package/dist/protocol/mcp.js +99 -32
  29. package/dist/protocol/settings-contract.js +4 -7
  30. package/dist/protocol/stdio.js +72 -0
  31. package/dist/protocol/task-contract.js +45 -18
  32. package/dist/shared/errors.js +15 -0
  33. package/dist/shared/limits.js +3 -0
  34. package/dist/tasks/definitions.js +19 -14
  35. package/dist/tasks/graph.js +5 -4
  36. package/dist/tasks/human-input.js +10 -9
  37. package/dist/tasks/runs.js +31 -30
  38. package/dist/tasks/schedule.js +2 -1
  39. package/dist/tasks/storage/records.js +6 -6
  40. package/dist/tasks/storage/store.js +8 -7
  41. package/dist/tasks/supervisor.js +10 -7
  42. package/dist/tasks/validation.js +3 -0
  43. package/dist/web/settings-view.js +43 -37
  44. package/docs/adr/0001-task-request-scope.md +3 -0
  45. package/docs/adr/0002-mcp-workspace-source.md +3 -0
  46. package/docs/adr/0003-adapter-installation.md +3 -0
  47. package/docs/architecture-guidelines.md +2 -2
  48. package/docs/cli.md +12 -4
  49. package/docs/deployment.md +52 -36
  50. package/docs/design-proposal.md +3 -0
  51. package/docs/release.md +27 -8
  52. package/docs/technical-design.md +5 -3
  53. package/docs/validation.md +59 -9
  54. package/docs/web-console.md +7 -4
  55. package/package.json +3 -1
  56. package/scripts/install-lib.mjs +5 -23
  57. package/scripts/probe-agent.mjs +10 -9
  58. package/scripts/service-runner.mjs +8 -3
  59. package/scripts/setup-wizard.mjs +38 -11
  60. package/scripts/setup.mjs +69 -95
@@ -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 an administrator-registered 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,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { authorizedProfile } from "../executors/registry.js";
2
+ import { TaskError } from "../shared/errors.js";
3
+ import { MAX_TASKS } from "../shared/limits.js";
3
4
  import { iso } from "../shared/time.js";
4
5
  import { validateGraph } from "./graph.js";
5
6
  import { hasUncertainOutcome, initialTime, resumeSchedule } from "./schedule.js";
@@ -18,28 +19,32 @@ export class TaskDefinitions {
18
19
  this.coordinatorConfigured = coordinatorConfigured;
19
20
  }
20
21
  check() { if (this.closed)
21
- throw new Error("Task definition service closed"); }
22
+ throw new TaskError("unavailable", "Task definition service closed"); }
22
23
  create(value, now = Date.now()) {
23
24
  this.check();
24
25
  if (!this.coordinatorConfigured)
25
- throw new Error("Coordinator is not configured");
26
+ throw new TaskError("precondition", "Coordinator is not configured");
26
27
  const input = taskData.parse(value), all = this.store.definitions();
27
- const existing = all.find(item => item.input.requestKey === input.requestKey);
28
+ if (input.workspace === undefined)
29
+ throw new TaskError("validation", "workspace is required without a local MCP launch directory");
30
+ const workspace = this.workspaces.resolve(input.workspace);
31
+ input.workspace = workspace;
32
+ const existing = all.find(item => item.workspace === workspace && item.input.requestKey === input.requestKey);
28
33
  if (existing) {
29
34
  if (JSON.stringify(existing.input) !== JSON.stringify(input))
30
- throw new Error("requestKey already used with different parameters");
35
+ throw new TaskError("conflict", "requestKey already used with different parameters");
31
36
  return existing;
32
37
  }
33
- if (all.length >= 1000)
34
- throw new Error("Task definition limit reached");
38
+ if (all.length >= MAX_TASKS)
39
+ throw new TaskError("precondition", "Task definition limit reached");
35
40
  validateGraph(input.nodes);
36
41
  const executors = this.executors.snapshot(input.nodes.map(node => node.executorId));
37
42
  const nextAt = initialTime(input.schedule, now);
38
43
  if (input.schedule.type !== "now" && nextAt < now)
39
- throw new Error("Schedule must start in the future");
44
+ throw new TaskError("validation", "Schedule must start in the future");
40
45
  if (input.expiresAt && Date.parse(input.expiresAt) <= Math.max(now, nextAt))
41
- throw new Error("Deadline must follow first trigger");
42
- const task = { id: randomUUID(), input, workspace: input.workspace === undefined ? null : this.workspaces.resolve(input.workspace),
46
+ throw new TaskError("validation", "Deadline must follow first trigger");
47
+ const task = { id: randomUUID(), input, workspace,
43
48
  executors, nextAt, status: "active", createdAt: iso(now) };
44
49
  this.store.saveDefinition(task);
45
50
  return task;
@@ -60,22 +65,22 @@ export class TaskDefinitions {
60
65
  }
61
66
  else {
62
67
  if (task.nextAt === null || ["cancelled", "expired"].includes(task.status))
63
- throw new Error("Task has no future schedule");
68
+ throw new TaskError("precondition", "Task has no future schedule");
64
69
  if (action === "pause")
65
70
  task.status = "paused";
66
71
  else {
67
72
  if (this.store.rounds(id).some(hasUncertainOutcome))
68
- 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");
69
74
  task = resumeSchedule(task, now);
70
75
  }
71
76
  }
72
77
  this.store.saveDefinition(task);
73
78
  return task;
74
79
  }
75
- executorImpact(profiles) {
80
+ executorImpact(disabled) {
76
81
  this.check();
77
82
  return this.store.definitions().filter(task => ((task.nextAt !== null && ["active", "paused"].includes(task.status)) || this.store.activeRounds(task.id).length > 0) &&
78
- Object.entries(task.executors).some(([id, snapshot]) => !authorizedProfile(snapshot, profiles.find(profile => profile.id === id))))
83
+ Object.values(task.executors).some(snapshot => disabled.includes(snapshot.type)))
79
84
  .map(task => ({ id: task.id, name: task.input.name }));
80
85
  }
81
86
  close() { this.closed = true; }
@@ -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");
126
- node.workspace = task.workspace ?? executor.cwd;
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");
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);
@@ -141,24 +142,24 @@ export class TaskRuns {
141
142
  // The no-replay barrier precedes worker creation, not just the ACP prompt.
142
143
  this.store.commitRun(run);
143
144
  return opening ? { kind: "open", nodeRunId: node.id, text, seconds: node.definition.maxRunSeconds,
144
- input: { executor: { ...executor, cwd: node.workspace }, inputFiles: node.inputFiles, resultsRoot: this.results.root } }
145
+ input: { executor, workspace: node.workspace, inputFiles: node.inputFiles, resultsRoot: this.results.root } }
145
146
  : { kind: "continue", nodeRunId: node.id, text };
146
147
  }
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
- id: z.string().uuid(), input: taskData, workspace: absolutePath.nullable(), executors: z.record(identifier, executorRecord),
7
- status: z.enum(["active", "paused", "cancelled", "completed", "expired"]),
6
+ id: z.string().uuid(), input: taskData, workspace: absolutePath, executors: z.record(identifier, executorRecord),
7
+ status: taskStatusSchema,
8
8
  nextAt: z.number().finite().nullable(), createdAt: timestamp,
9
- }).strict();
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 {
@@ -14,7 +14,7 @@ export class TaskStore {
14
14
  try {
15
15
  const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
16
16
  if (tables.length) {
17
- this.db.prepare("SELECT seq,id,request_key,data FROM definitions LIMIT 0").all();
17
+ this.db.prepare("SELECT seq,id,workspace,request_key,data FROM definitions LIMIT 0").all();
18
18
  this.db.prepare("SELECT seq,id,task_id,scheduled_at,data FROM rounds LIMIT 0").all();
19
19
  this.definitions();
20
20
  this.rounds();
@@ -22,7 +22,8 @@ export class TaskStore {
22
22
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON");
23
23
  if (!tables.length)
24
24
  this.db.exec(`BEGIN IMMEDIATE;
25
- CREATE TABLE definitions(seq INTEGER PRIMARY KEY, id TEXT UNIQUE NOT NULL, request_key TEXT UNIQUE NOT NULL, data TEXT NOT NULL);
25
+ CREATE TABLE definitions(seq INTEGER PRIMARY KEY, id TEXT UNIQUE NOT NULL, workspace TEXT NOT NULL,
26
+ request_key TEXT NOT NULL, data TEXT NOT NULL, UNIQUE(workspace,request_key));
26
27
  CREATE TABLE rounds(seq INTEGER PRIMARY KEY, id TEXT UNIQUE NOT NULL, task_id TEXT NOT NULL REFERENCES definitions(id),
27
28
  scheduled_at REAL NOT NULL, data TEXT NOT NULL, UNIQUE(task_id,scheduled_at));
28
29
  COMMIT;`);
@@ -64,14 +65,14 @@ export class TaskStore {
64
65
  this.check();
65
66
  const row = this.db.prepare("SELECT data FROM definitions WHERE id=?").get(id);
66
67
  if (!row)
67
- throw new Error("Task not found");
68
+ throw new TaskError("not_found", "Task not found");
68
69
  return this.decode(definitionSchema, row["data"]);
69
70
  }
70
71
  run(id) {
71
72
  this.check();
72
73
  const row = this.db.prepare("SELECT data FROM rounds WHERE id=?").get(id);
73
74
  if (!row)
74
- throw new Error("Task run not found");
75
+ throw new TaskError("not_found", "Task run not found");
75
76
  return this.decode(roundSchema, row["data"]);
76
77
  }
77
78
  /** The service supplies all policy; the store commits a cursor and round atomically. */
@@ -86,8 +87,8 @@ export class TaskStore {
86
87
  try {
87
88
  this.db.exec("BEGIN IMMEDIATE");
88
89
  if (definition)
89
- this.db.prepare(`INSERT INTO definitions(id,request_key,data) VALUES(?,?,?)
90
- ON CONFLICT(id) DO UPDATE SET data=excluded.data`).run(definition.id, definition.input.requestKey, JSON.stringify(definition));
90
+ this.db.prepare(`INSERT INTO definitions(id,workspace,request_key,data) VALUES(?,?,?,?)
91
+ ON CONFLICT(id) DO UPDATE SET data=excluded.data`).run(definition.id, definition.workspace, definition.input.requestKey, JSON.stringify(definition));
91
92
  if (round)
92
93
  this.db.prepare(`INSERT INTO rounds(id,task_id,scheduled_at,data) VALUES(?,?,?,?)
93
94
  ON CONFLICT(id) DO UPDATE SET data=excluded.data`).run(round.id, round.taskId, round.scheduledAt, JSON.stringify(round));
@@ -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,15 +127,15 @@ 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
- this.timer = setInterval(() => this.tick(), 500);
138
+ this.timer = setInterval(() => this.tick(), 10_000);
136
139
  this.tick();
137
140
  }
138
141
  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),