@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.
- package/CONTEXT.md +17 -0
- package/README.md +28 -6
- package/bin/agent-task.mjs +18 -6
- package/config.example.json +1 -1
- package/dist/application/launcher.js +30 -16
- package/dist/application/main.js +5 -2
- package/dist/application/mcp-entry.js +71 -0
- package/dist/application/task-runtime.js +3 -3
- package/dist/configuration/contract.js +2 -2
- package/dist/configuration/files.js +2 -3
- package/dist/configuration/management.js +1 -1
- package/dist/configuration/policy.js +3 -8
- package/dist/configuration/settings.js +10 -33
- package/dist/executors/ports.js +2 -0
- package/dist/executors/registry.js +117 -15
- package/dist/executors/validation.js +4 -5
- package/dist/infrastructure/lock.js +25 -13
- package/dist/infrastructure/workspace.js +4 -3
- package/dist/integrations/acp/connection.js +5 -5
- package/dist/integrations/acp/discovery.js +271 -0
- package/dist/integrations/acp/runtime.js +38 -23
- package/dist/integrations/acp/worker.js +5 -3
- package/dist/integrations/local-service/control.js +36 -11
- package/dist/protocol/cli/commands.js +41 -46
- package/dist/protocol/http/server.js +8 -0
- package/dist/protocol/http/web.js +7 -1
- package/dist/protocol/mcp-contract.js +194 -0
- package/dist/protocol/mcp.js +99 -32
- package/dist/protocol/settings-contract.js +4 -7
- package/dist/protocol/stdio.js +72 -0
- 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 +19 -14
- package/dist/tasks/graph.js +5 -4
- package/dist/tasks/human-input.js +10 -9
- package/dist/tasks/runs.js +31 -30
- package/dist/tasks/schedule.js +2 -1
- package/dist/tasks/storage/records.js +6 -6
- package/dist/tasks/storage/store.js +8 -7
- package/dist/tasks/supervisor.js +10 -7
- package/dist/tasks/validation.js +3 -0
- package/dist/web/settings-view.js +43 -37
- package/docs/adr/0001-task-request-scope.md +3 -0
- package/docs/adr/0002-mcp-workspace-source.md +3 -0
- package/docs/adr/0003-adapter-installation.md +3 -0
- package/docs/architecture-guidelines.md +2 -2
- package/docs/cli.md +12 -4
- package/docs/deployment.md +52 -36
- package/docs/design-proposal.md +3 -0
- package/docs/release.md +27 -8
- package/docs/technical-design.md +5 -3
- package/docs/validation.md +59 -9
- package/docs/web-console.md +7 -4
- package/package.json +3 -1
- package/scripts/install-lib.mjs +5 -23
- package/scripts/probe-agent.mjs +10 -9
- package/scripts/service-runner.mjs +8 -3
- package/scripts/setup-wizard.mjs +38 -11
- 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
|
-
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
26
|
+
throw new TaskError("precondition", "Coordinator is not configured");
|
|
26
27
|
const input = taskData.parse(value), all = this.store.definitions();
|
|
27
|
-
|
|
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
|
|
35
|
+
throw new TaskError("conflict", "requestKey already used with different parameters");
|
|
31
36
|
return existing;
|
|
32
37
|
}
|
|
33
|
-
if (all.length >=
|
|
34
|
-
throw new
|
|
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
|
|
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
|
|
42
|
-
const task = { id: randomUUID(), 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
|
|
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
|
|
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(
|
|
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.
|
|
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; }
|
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
|
|
126
|
-
node.workspace = task.workspace
|
|
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
|
|
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
|
|
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
|
|
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
|
-
id: z.string().uuid(), input: taskData, workspace: absolutePath
|
|
7
|
-
status:
|
|
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:
|
|
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 {
|
|
@@ -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,
|
|
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
|
|
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
|
|
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));
|
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,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
|
|
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
|
-
this.timer = setInterval(() => this.tick(),
|
|
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 >=
|
|
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),
|