@realqiyan/agent-task 0.0.1-rc.2 → 0.0.1-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/bin/agent-task.mjs +23 -36
- package/dist/{launcher.js → application/launcher.js} +19 -42
- package/dist/application/main.js +122 -0
- package/dist/{startup.js → application/startup.js} +6 -11
- package/dist/application/task-runtime.js +42 -0
- package/dist/configuration/contract.js +28 -0
- package/dist/{environment.js → configuration/environment.js} +1 -5
- package/dist/configuration/files.js +43 -0
- package/dist/configuration/management.js +6 -0
- package/dist/configuration/model.js +2 -0
- package/dist/configuration/policy.js +29 -0
- package/dist/configuration/ports.js +2 -0
- package/dist/{settings.js → configuration/settings.js} +77 -39
- package/dist/executors/environment.js +10 -0
- package/dist/executors/model.js +2 -0
- package/dist/executors/registry.js +29 -0
- package/dist/executors/validation.js +10 -0
- package/dist/{lock.js → infrastructure/lock.js} +2 -2
- package/dist/{private-files.js → infrastructure/private-files.js} +1 -7
- package/dist/infrastructure/sqlite.js +19 -0
- package/dist/infrastructure/version.js +3 -0
- package/dist/{acp.js → integrations/acp/connection.js} +9 -7
- package/dist/{node-execution.js → integrations/acp/execution.js} +1 -1
- package/dist/{executor-runtime.js → integrations/acp/runtime.js} +1 -10
- package/dist/{worker.js → integrations/acp/worker.js} +3 -2
- package/dist/{coordinator.js → integrations/coordinator/coordinator.js} +16 -7
- package/dist/{coordinator-provider.js → integrations/coordinator/provider.js} +1 -1
- package/dist/{coordinator-storage.js → integrations/coordinator/storage.js} +2 -2
- package/dist/integrations/local-service/control.js +33 -0
- package/dist/protocol/cli/admin-client.js +45 -0
- package/dist/protocol/cli/commands.js +394 -0
- package/dist/{http-auth.js → protocol/http/auth.js} +1 -1
- package/dist/protocol/http/server.js +111 -0
- package/dist/{web.js → protocol/http/web.js} +18 -17
- package/dist/{mcp.js → protocol/mcp.js} +13 -13
- package/dist/protocol/services.js +2 -0
- package/dist/protocol/settings-contract.js +40 -0
- package/dist/protocol/task-contract.js +28 -0
- package/dist/results/files.js +70 -0
- package/dist/results/ports.js +2 -0
- package/dist/results/service.js +42 -0
- package/dist/shared/errors.js +13 -0
- package/dist/shared/limits.js +3 -0
- package/dist/shared/time.js +2 -0
- package/dist/shared/validation.js +8 -0
- package/dist/tasks/definitions.js +83 -0
- package/dist/tasks/graph.js +23 -0
- package/dist/{human-input.js → tasks/human-input.js} +8 -7
- package/dist/tasks/model.js +2 -0
- package/dist/tasks/ports.js +2 -0
- package/dist/tasks/runs.js +263 -0
- package/dist/tasks/schedule.js +36 -0
- package/dist/tasks/sessions.js +62 -0
- package/dist/tasks/storage/records.js +45 -0
- package/dist/{task-storage.js → tasks/storage/store.js} +23 -36
- package/dist/tasks/supervisor.js +227 -0
- package/dist/tasks/validation.js +24 -0
- package/dist/web/settings-view.js +13 -6
- package/docs/architecture-guidelines.md +6 -4
- package/docs/cli.md +49 -0
- package/docs/deployment.md +16 -56
- package/docs/design-proposal.md +2 -2
- package/docs/release.md +14 -15
- package/docs/technical-design.md +47 -26
- package/docs/validation.md +49 -17
- package/docs/web-console.md +10 -8
- package/package.json +7 -7
- package/scripts/install-lib.mjs +4 -95
- package/scripts/mcp-call.mjs +1 -1
- package/scripts/probe-agent.mjs +6 -5
- package/scripts/service-runner.mjs +1 -1
- package/scripts/setup-wizard.mjs +1 -6
- package/scripts/setup.mjs +17 -30
- package/deploy/agent-task.service.example +0 -17
- package/dist/admin.js +0 -31
- package/dist/config.js +0 -72
- package/dist/main.js +0 -175
- package/dist/model.js +0 -25
- package/dist/results.js +0 -79
- package/dist/settings-contract.js +0 -16
- package/dist/task-contract.js +0 -37
- package/dist/task-model.js +0 -2
- package/dist/tasks.js +0 -508
- package/dist/version.js +0 -3
- package/scripts/uninstall.mjs +0 -57
- /package/dist/{permissions.js → executors/permissions.js} +0 -0
- /package/dist/{workspace.js → infrastructure/workspace.js} +0 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function hasUncertainOutcome(run) {
|
|
2
|
+
return run.nodes.some(node => node.status === "interrupted");
|
|
3
|
+
}
|
|
4
|
+
export function initialTime(schedule, now) {
|
|
5
|
+
return schedule.type === "now" ? now : Date.parse(schedule.type === "once" ? schedule.at : schedule.startAt);
|
|
6
|
+
}
|
|
7
|
+
export function resumeSchedule(task, now) {
|
|
8
|
+
const next = structuredClone(task);
|
|
9
|
+
if (next.nextAt === null || ["cancelled", "expired"].includes(next.status))
|
|
10
|
+
throw new Error("Task has no future schedule");
|
|
11
|
+
next.status = "active";
|
|
12
|
+
if (next.nextAt < now) {
|
|
13
|
+
if (next.input.schedule.type === "interval")
|
|
14
|
+
next.nextAt += (Math.floor((now - next.nextAt) / (next.input.schedule.seconds * 1000)) + 1) * next.input.schedule.seconds * 1000;
|
|
15
|
+
else {
|
|
16
|
+
next.nextAt = null;
|
|
17
|
+
next.status = "completed";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return next;
|
|
21
|
+
}
|
|
22
|
+
/** Calendar only. Node dispatch remains the coordinator's decision. */
|
|
23
|
+
export function dueTrigger(task, overlaps, now) {
|
|
24
|
+
if (task.status !== "active" || task.nextAt === null)
|
|
25
|
+
return { kind: "none" };
|
|
26
|
+
if (task.input.expiresAt && now >= Date.parse(task.input.expiresAt))
|
|
27
|
+
return { kind: "expired" };
|
|
28
|
+
if (task.nextAt > now)
|
|
29
|
+
return { kind: "none" };
|
|
30
|
+
const schedule = task.input.schedule;
|
|
31
|
+
const count = schedule.type === "interval" ? Math.floor((now - task.nextAt) / (schedule.seconds * 1000)) + 1 : 1;
|
|
32
|
+
const at = task.nextAt + (schedule.type === "interval" ? (count - 1) * schedule.seconds * 1000 : 0);
|
|
33
|
+
return { kind: "due", at, count, nextAt: schedule.type === "interval" ? at + schedule.seconds * 1000 : null,
|
|
34
|
+
reason: overlaps ? "Previous round still active; slot skipped" : now - at > 30_000 ? "Late slot skipped" : null };
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=schedule.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Owns every opened handle and its lifetime timer, including sessions retained while waiting for facts. */
|
|
2
|
+
export class NodeSessions {
|
|
3
|
+
openExecution;
|
|
4
|
+
fatal;
|
|
5
|
+
sessions = new Map();
|
|
6
|
+
closing;
|
|
7
|
+
constructor(openExecution, fatal) {
|
|
8
|
+
this.openExecution = openExecution;
|
|
9
|
+
this.fatal = fatal;
|
|
10
|
+
}
|
|
11
|
+
get size() { return this.sessions.size; }
|
|
12
|
+
has(id) { return !this.closing && this.sessions.get(id)?.state === "open"; }
|
|
13
|
+
runIds() { return [...new Set([...this.sessions.values()].map(value => value.runId))]; }
|
|
14
|
+
nodeIds() { return [...this.sessions.keys()]; }
|
|
15
|
+
get(id) {
|
|
16
|
+
const active = this.sessions.get(id);
|
|
17
|
+
if (this.closing || active?.state !== "open")
|
|
18
|
+
throw new Error("ACP session unavailable or closing");
|
|
19
|
+
return active.handle;
|
|
20
|
+
}
|
|
21
|
+
open(id, runId, input, seconds, event, beforeStart, expired, disconnected) {
|
|
22
|
+
if (this.closing)
|
|
23
|
+
throw new Error("Node sessions closed");
|
|
24
|
+
if (this.sessions.has(id))
|
|
25
|
+
throw new Error("Node session already exists");
|
|
26
|
+
const handle = this.openExecution(input, event, beforeStart);
|
|
27
|
+
const timer = setTimeout(expired, seconds * 1000);
|
|
28
|
+
this.sessions.set(id, { runId, handle, state: "open", timer });
|
|
29
|
+
void handle.closed.then(() => {
|
|
30
|
+
if (this.sessions.get(id)?.state === "open" && !this.closing)
|
|
31
|
+
disconnected();
|
|
32
|
+
}).catch(this.fatal);
|
|
33
|
+
return handle;
|
|
34
|
+
}
|
|
35
|
+
closeSession(id) {
|
|
36
|
+
const owned = this.sessions.get(id);
|
|
37
|
+
if (!owned)
|
|
38
|
+
return Promise.resolve();
|
|
39
|
+
if (owned.state === "closing")
|
|
40
|
+
return owned.completion;
|
|
41
|
+
clearTimeout(owned.timer);
|
|
42
|
+
// Mark closing before the handle can report a disconnect; concurrent close calls share completion.
|
|
43
|
+
const completion = Promise.resolve().then(() => owned.handle.close()).then(() => { this.sessions.delete(id); });
|
|
44
|
+
this.sessions.set(id, { runId: owned.runId, handle: owned.handle, state: "closing", completion });
|
|
45
|
+
return completion;
|
|
46
|
+
}
|
|
47
|
+
async closeRun(id) {
|
|
48
|
+
const results = await Promise.allSettled([...this.sessions].filter(([, value]) => value.runId === id).map(([key]) => this.closeSession(key)));
|
|
49
|
+
const failure = results.find(result => result.status === "rejected");
|
|
50
|
+
if (failure?.status === "rejected")
|
|
51
|
+
throw failure.reason;
|
|
52
|
+
}
|
|
53
|
+
close() {
|
|
54
|
+
return this.closing ??= (async () => {
|
|
55
|
+
const results = await Promise.allSettled([...this.sessions.keys()].map(id => this.closeSession(id)));
|
|
56
|
+
const failure = results.find(result => result.status === "rejected");
|
|
57
|
+
if (failure?.status === "rejected")
|
|
58
|
+
throw failure.reason;
|
|
59
|
+
})();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=sessions.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { executorRecord } from "../../executors/validation.js";
|
|
3
|
+
import { absolutePath, identifier, text, timestamp } from "../../shared/validation.js";
|
|
4
|
+
import { nodeData, questionData, taskData } from "../validation.js";
|
|
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"]),
|
|
8
|
+
nextAt: z.number().finite().nullable(), createdAt: timestamp,
|
|
9
|
+
}).strict();
|
|
10
|
+
export const roundSchema = z.object({
|
|
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"]),
|
|
13
|
+
createdAt: timestamp, finishedAt: timestamp.nullable(), error: z.string().nullable(), skippedCount: z.number().int().nonnegative(),
|
|
14
|
+
steps: z.number().int().nonnegative(), coordinatorStartedAt: timestamp.nullable(), summary: z.string().nullable(),
|
|
15
|
+
waitedMs: z.number().finite().nonnegative(),
|
|
16
|
+
questions: z.array(questionData.extend({
|
|
17
|
+
id: z.string().uuid(), createdAt: timestamp, expiresAt: timestamp, sessionExpiresAt: timestamp.nullable(),
|
|
18
|
+
reply: z.object({ requestKey: identifier, answer: text(16000), receivedAt: timestamp }).strict().nullable(),
|
|
19
|
+
})).max(20),
|
|
20
|
+
nodes: z.array(z.object({
|
|
21
|
+
id: z.string().uuid(), definition: nodeData,
|
|
22
|
+
status: z.enum(["pending", "running", "awaiting_review", "succeeded", "failed", "blocked", "interrupted"]),
|
|
23
|
+
workspace: absolutePath.nullable(), inputFiles: z.array(absolutePath), resultFile: absolutePath.nullable(), result: z.string().nullable(), acceptance: z.string().nullable(),
|
|
24
|
+
turns: z.array(z.object({ prompt: z.string(), output: z.string(), stopReason: z.string().nullable() }).strict()),
|
|
25
|
+
error: z.string().nullable(), sessionId: z.string().nullable(), startedAt: timestamp.nullable(), eventCount: z.number().int().nonnegative(),
|
|
26
|
+
}).strict().superRefine((node, context) => {
|
|
27
|
+
if (["running", "awaiting_review", "succeeded"].includes(node.status) && (!node.startedAt || !node.turns.length)) {
|
|
28
|
+
context.addIssue({ code: "custom", message: "Started nodes require a start time and turn" });
|
|
29
|
+
}
|
|
30
|
+
if (node.status === "succeeded" && (!node.resultFile || !node.result?.trim() || !node.acceptance?.trim() || node.turns.at(-1)?.stopReason !== "end_turn")) {
|
|
31
|
+
context.addIssue({ code: "custom", message: "Accepted node requires a completed turn and delivery" });
|
|
32
|
+
}
|
|
33
|
+
})).min(1).max(20),
|
|
34
|
+
}).strict().superRefine((run, context) => {
|
|
35
|
+
const live = ["running", "waiting_input"].includes(run.status);
|
|
36
|
+
if (live !== (run.finishedAt === null))
|
|
37
|
+
context.addIssue({ code: "custom", message: "Run completion time does not match status" });
|
|
38
|
+
if (run.status === "waiting_input" && (!run.questions.length || run.questions.at(-1).reply !== null)) {
|
|
39
|
+
context.addIssue({ code: "custom", message: "Waiting run requires an unanswered question" });
|
|
40
|
+
}
|
|
41
|
+
if (run.status === "succeeded" && (!run.summary?.trim() || run.nodes.some(node => node.status !== "succeeded"))) {
|
|
42
|
+
context.addIssue({ code: "custom", message: "Successful run requires accepted nodes and summary" });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
//# sourceMappingURL=records.js.map
|
|
@@ -1,34 +1,8 @@
|
|
|
1
|
-
import { DatabaseSync } from "
|
|
1
|
+
import { DatabaseSync } from "../../infrastructure/sqlite.js";
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { nodeSchema, questionSchema, taskSchema } from "./task-contract.js";
|
|
7
|
-
const definitionSchema = z.object({
|
|
8
|
-
id: z.string().uuid(), input: taskSchema, workspace: z.string().nullable(), executors: z.record(identifier, executorSchema),
|
|
9
|
-
status: z.enum(["active", "paused", "cancelled", "completed", "expired"]),
|
|
10
|
-
nextAt: z.number().finite().nullable(), createdAt: timestamp,
|
|
11
|
-
}).strict();
|
|
12
|
-
const roundSchema = z.object({
|
|
13
|
-
id: z.string().uuid(), taskId: z.string().uuid(), scheduledAt: z.number().finite(),
|
|
14
|
-
status: z.enum(["running", "succeeded", "failed", "interrupted", "skipped", "cancelled", "timed_out", "waiting_input"]),
|
|
15
|
-
createdAt: timestamp, finishedAt: timestamp.nullable(), error: z.string().nullable(), skippedCount: z.number().int().nonnegative(),
|
|
16
|
-
steps: z.number().int().nonnegative(), coordinatorStartedAt: timestamp.nullable(), summary: z.string().nullable(),
|
|
17
|
-
waitedMs: z.number().finite().nonnegative(),
|
|
18
|
-
questions: z.array(questionSchema.extend({
|
|
19
|
-
id: z.string().uuid(), createdAt: timestamp, expiresAt: timestamp, sessionExpiresAt: timestamp.nullable(),
|
|
20
|
-
reply: z.object({ requestKey: identifier, answer: z.string().min(1).max(16000), receivedAt: timestamp }).strict().nullable(),
|
|
21
|
-
})).max(20),
|
|
22
|
-
nodes: z.array(z.object({
|
|
23
|
-
id: z.string().uuid(), definition: nodeSchema,
|
|
24
|
-
status: z.enum(["pending", "running", "awaiting_review", "succeeded", "failed", "blocked", "interrupted"]),
|
|
25
|
-
workspace: z.string().nullable(), inputFiles: z.array(z.string()), resultFile: z.string().nullable(), result: z.string().nullable(), acceptance: z.string().nullable(),
|
|
26
|
-
turns: z.array(z.object({ prompt: z.string(), output: z.string(), stopReason: z.string().nullable() }).strict()),
|
|
27
|
-
error: z.string().nullable(), sessionId: z.string().nullable(), startedAt: timestamp.nullable(), eventCount: z.number().int().nonnegative(),
|
|
28
|
-
}).strict()).min(1).max(20),
|
|
29
|
-
}).strict();
|
|
30
|
-
export class StorageError extends Error {
|
|
31
|
-
}
|
|
4
|
+
import { StorageError } from "../../shared/errors.js";
|
|
5
|
+
import { definitionSchema, roundSchema } from "./records.js";
|
|
32
6
|
/** Task definitions and rounds share one transactional store. */
|
|
33
7
|
export class TaskStore {
|
|
34
8
|
db;
|
|
@@ -42,6 +16,8 @@ export class TaskStore {
|
|
|
42
16
|
if (tables.length) {
|
|
43
17
|
this.db.prepare("SELECT seq,id,request_key,data FROM definitions LIMIT 0").all();
|
|
44
18
|
this.db.prepare("SELECT seq,id,task_id,scheduled_at,data FROM rounds LIMIT 0").all();
|
|
19
|
+
this.definitions();
|
|
20
|
+
this.rounds();
|
|
45
21
|
}
|
|
46
22
|
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON");
|
|
47
23
|
if (!tables.length)
|
|
@@ -52,44 +28,55 @@ export class TaskStore {
|
|
|
52
28
|
COMMIT;`);
|
|
53
29
|
}
|
|
54
30
|
catch (error) {
|
|
55
|
-
this.
|
|
31
|
+
this.close();
|
|
56
32
|
throw error;
|
|
57
33
|
}
|
|
58
34
|
}
|
|
59
35
|
check() { if (this.closed)
|
|
60
36
|
throw new StorageError("Task storage closed"); }
|
|
37
|
+
decode(schema, data) {
|
|
38
|
+
try {
|
|
39
|
+
return schema.parse(JSON.parse(String(data)));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
this.close();
|
|
43
|
+
throw new StorageError("Invalid stored task data; no conversion or repair performed", { cause: error });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
61
46
|
definitions() {
|
|
62
47
|
this.check();
|
|
63
|
-
return this.db.prepare("SELECT data FROM definitions ORDER BY seq").all().map(row =>
|
|
48
|
+
return this.db.prepare("SELECT data FROM definitions ORDER BY seq").all().map(row => this.decode(definitionSchema, row["data"]));
|
|
64
49
|
}
|
|
65
50
|
rounds(taskId) {
|
|
66
51
|
this.check();
|
|
67
52
|
const rows = taskId ? this.db.prepare("SELECT data FROM rounds WHERE task_id=? ORDER BY seq").all(taskId)
|
|
68
53
|
: this.db.prepare("SELECT data FROM rounds ORDER BY seq").all();
|
|
69
|
-
return rows.map(row =>
|
|
54
|
+
return rows.map(row => this.decode(roundSchema, row["data"]));
|
|
70
55
|
}
|
|
71
56
|
activeRounds(taskId) {
|
|
72
57
|
this.check();
|
|
73
58
|
const rows = taskId
|
|
74
59
|
? this.db.prepare("SELECT data FROM rounds WHERE task_id=? AND json_extract(data,'$.status') IN ('running','waiting_input')").all(taskId)
|
|
75
60
|
: this.db.prepare("SELECT data FROM rounds WHERE json_extract(data,'$.status') IN ('running','waiting_input')").all();
|
|
76
|
-
return rows.map(row =>
|
|
61
|
+
return rows.map(row => this.decode(roundSchema, row["data"]));
|
|
77
62
|
}
|
|
78
63
|
get(id) {
|
|
79
64
|
this.check();
|
|
80
65
|
const row = this.db.prepare("SELECT data FROM definitions WHERE id=?").get(id);
|
|
81
66
|
if (!row)
|
|
82
67
|
throw new Error("Task not found");
|
|
83
|
-
return
|
|
68
|
+
return this.decode(definitionSchema, row["data"]);
|
|
84
69
|
}
|
|
85
70
|
run(id) {
|
|
86
71
|
this.check();
|
|
87
72
|
const row = this.db.prepare("SELECT data FROM rounds WHERE id=?").get(id);
|
|
88
73
|
if (!row)
|
|
89
74
|
throw new Error("Task run not found");
|
|
90
|
-
return
|
|
75
|
+
return this.decode(roundSchema, row["data"]);
|
|
91
76
|
}
|
|
92
77
|
/** The service supplies all policy; the store commits a cursor and round atomically. */
|
|
78
|
+
saveDefinition(task) { this.commit(task); }
|
|
79
|
+
commitRun(run, task) { this.commit(task, run); }
|
|
93
80
|
commit(definition, round) {
|
|
94
81
|
this.check();
|
|
95
82
|
if (definition)
|
|
@@ -120,4 +107,4 @@ export class TaskStore {
|
|
|
120
107
|
this.db.close();
|
|
121
108
|
} }
|
|
122
109
|
}
|
|
123
|
-
//# sourceMappingURL=
|
|
110
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { errorText, StorageError } from "../shared/errors.js";
|
|
2
|
+
import { coordinatorInput } from "./human-input.js";
|
|
3
|
+
import { NodeSessions } from "./sessions.js";
|
|
4
|
+
/** Owns coordinator generations, the calendar clock and NodeSessions. All persisted decisions go through TaskRuns. */
|
|
5
|
+
export class RunSupervisor {
|
|
6
|
+
runs;
|
|
7
|
+
coordinate;
|
|
8
|
+
fatal;
|
|
9
|
+
closed = false;
|
|
10
|
+
closing;
|
|
11
|
+
timer;
|
|
12
|
+
active = new Map();
|
|
13
|
+
turns = new Set();
|
|
14
|
+
sessions;
|
|
15
|
+
constructor(runs, openExecution, coordinate, fatal) {
|
|
16
|
+
this.runs = runs;
|
|
17
|
+
this.coordinate = coordinate;
|
|
18
|
+
this.fatal = fatal;
|
|
19
|
+
this.sessions = new NodeSessions(openExecution, fatal);
|
|
20
|
+
}
|
|
21
|
+
check() { if (this.closed)
|
|
22
|
+
throw new Error("Run supervisor closed"); }
|
|
23
|
+
status() {
|
|
24
|
+
return { running: !this.closed && this.timer !== undefined && this.runs.healthy,
|
|
25
|
+
activeTaskRunIds: [...this.active.keys()], activeNodeRunIds: this.sessions.nodeIds(), storageHealthy: this.runs.healthy };
|
|
26
|
+
}
|
|
27
|
+
actions(id, requestId = coordinatorInput(this.runs.get(id)).requestId) {
|
|
28
|
+
this.check();
|
|
29
|
+
const current = () => { this.check(); return this.runs.current(id, requestId); };
|
|
30
|
+
return {
|
|
31
|
+
inspect: () => { current(); return this.runs.inspect(id, requestId); },
|
|
32
|
+
step: () => { current(); try {
|
|
33
|
+
this.runs.step(id);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
this.reconcile(id);
|
|
37
|
+
} },
|
|
38
|
+
converse: (node, message) => { current(); return this.converse(id, node, message); },
|
|
39
|
+
accept: (node, turn, result, reason) => { current(); return this.accept(id, node, turn, result, reason); },
|
|
40
|
+
ask: request => {
|
|
41
|
+
current();
|
|
42
|
+
const question = this.runs.ask(id, request);
|
|
43
|
+
clearTimeout(this.active.get(id)?.timer);
|
|
44
|
+
return question;
|
|
45
|
+
},
|
|
46
|
+
finish: summary => { current(); this.runs.finish(id, summary); },
|
|
47
|
+
stop: (status, reason) => { current(); this.stopActive(id, status, reason); },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
converse(id, nodeId, message) {
|
|
51
|
+
const pending = this.performTurn(id, nodeId, message);
|
|
52
|
+
this.turns.add(pending);
|
|
53
|
+
void pending.finally(() => this.turns.delete(pending)).catch(() => { });
|
|
54
|
+
return pending;
|
|
55
|
+
}
|
|
56
|
+
async performTurn(id, nodeId, message) {
|
|
57
|
+
this.check();
|
|
58
|
+
let dispatched = false;
|
|
59
|
+
try {
|
|
60
|
+
const dispatch = this.runs.prepareTurn(id, nodeId, message, this.sessions);
|
|
61
|
+
dispatched = true;
|
|
62
|
+
const handle = dispatch.kind === "continue" ? this.sessions.get(dispatch.nodeRunId)
|
|
63
|
+
: this.sessions.open(dispatch.nodeRunId, id, dispatch.input, dispatch.seconds, (kind, data, text) => this.runs.record(id, nodeId, kind, data, text), () => { this.check(); this.runs.beforeStart(id, nodeId); }, () => {
|
|
64
|
+
const waiting = this.runs.get(id).status === "waiting_input";
|
|
65
|
+
this.stopActive(id, waiting ? "interrupted" : "timed_out", waiting
|
|
66
|
+
? "ACP session lifetime expired while waiting; no replay" : "ACP node session timed out");
|
|
67
|
+
}, () => { if (!this.closed && this.runs.healthy)
|
|
68
|
+
this.stopActive(id, "interrupted", "ACP session disconnected; external effects unknown"); });
|
|
69
|
+
const result = await handle.prompt(dispatch.text);
|
|
70
|
+
return this.runs.completeTurn(id, nodeId, result.stopReason);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof StorageError)
|
|
74
|
+
this.fatal(error);
|
|
75
|
+
else if (dispatched && !this.closed && this.runs.healthy)
|
|
76
|
+
this.stopActive(id, "interrupted", errorText(error));
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
if (!this.closed)
|
|
81
|
+
this.reconcile(id);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async accept(id, nodeId, turn, result, reason) {
|
|
85
|
+
this.check();
|
|
86
|
+
const owned = this.runs.accept(id, nodeId, turn, result, reason);
|
|
87
|
+
await this.sessions.closeSession(owned);
|
|
88
|
+
}
|
|
89
|
+
reconcile(id) {
|
|
90
|
+
if (!this.runs.healthy)
|
|
91
|
+
return;
|
|
92
|
+
if (["running", "waiting_input"].includes(this.runs.get(id).status))
|
|
93
|
+
return;
|
|
94
|
+
this.active.get(id)?.controller.abort(new Error("Run stopped"));
|
|
95
|
+
void this.sessions.closeRun(id).catch(this.fatal);
|
|
96
|
+
}
|
|
97
|
+
stopActive(id, status, reason) {
|
|
98
|
+
try {
|
|
99
|
+
this.runs.end(id, status, reason);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
this.fatal(error);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
this.active.get(id)?.controller.abort(new Error(reason));
|
|
106
|
+
void this.sessions.closeRun(id).catch(this.fatal);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
reply(input, now = Date.now()) {
|
|
110
|
+
this.check();
|
|
111
|
+
try {
|
|
112
|
+
return this.runs.reply(input, id => this.sessions.has(id), now);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
this.reconcile(input.runId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
cancel(id) {
|
|
119
|
+
this.check();
|
|
120
|
+
this.runs.get(id);
|
|
121
|
+
this.stopActive(id, "cancelled", "Run cancelled; external effects are not rolled back");
|
|
122
|
+
return this.runs.get(id);
|
|
123
|
+
}
|
|
124
|
+
recover() {
|
|
125
|
+
this.check();
|
|
126
|
+
if (this.active.size || this.sessions.size)
|
|
127
|
+
throw new Error("Recovery requires no active resources");
|
|
128
|
+
this.runs.recover();
|
|
129
|
+
}
|
|
130
|
+
start() {
|
|
131
|
+
this.check();
|
|
132
|
+
if (this.timer)
|
|
133
|
+
throw new Error("Run supervisor already started");
|
|
134
|
+
this.recover();
|
|
135
|
+
this.timer = setInterval(() => this.tick(), 500);
|
|
136
|
+
this.tick();
|
|
137
|
+
}
|
|
138
|
+
tick() {
|
|
139
|
+
this.check();
|
|
140
|
+
try {
|
|
141
|
+
this.runs.trigger();
|
|
142
|
+
for (const run of this.runs.active()) {
|
|
143
|
+
if (run.status === "waiting_input") {
|
|
144
|
+
this.runs.expireWaiting(run.id);
|
|
145
|
+
this.reconcile(run.id);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (this.active.has(run.id) || this.active.size >= 4)
|
|
149
|
+
continue;
|
|
150
|
+
this.launch(run.id);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (this.timer)
|
|
155
|
+
clearInterval(this.timer);
|
|
156
|
+
this.fatal(error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
launch(id) {
|
|
160
|
+
const coordinate = this.coordinate;
|
|
161
|
+
if (!coordinate) {
|
|
162
|
+
this.runs.end(id, "failed", "Coordinator not configured");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const admission = this.runs.admitCoordinator(id);
|
|
166
|
+
if (!admission)
|
|
167
|
+
return;
|
|
168
|
+
const { input, remaining } = admission;
|
|
169
|
+
const stillCurrent = () => {
|
|
170
|
+
const current = this.runs.get(id);
|
|
171
|
+
return current.status === "running" && coordinatorInput(current).requestId === input.requestId;
|
|
172
|
+
};
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
const timer = setTimeout(() => this.stopActive(id, "timed_out", "Task run timed out"), remaining);
|
|
175
|
+
// Register the controller before the deferred external entry. Recheck generation and cancellation there.
|
|
176
|
+
const completion = Promise.resolve().then(() => {
|
|
177
|
+
this.check();
|
|
178
|
+
controller.signal.throwIfAborted();
|
|
179
|
+
this.runs.current(id, input.requestId);
|
|
180
|
+
return coordinate(id, input, this.actions(id, input.requestId), controller.signal);
|
|
181
|
+
}).then(() => {
|
|
182
|
+
if (!this.closed && stillCurrent())
|
|
183
|
+
this.runs.end(id, "failed", "Coordinator ended without an explicit completion or blocking decision");
|
|
184
|
+
}, error => {
|
|
185
|
+
if (error instanceof StorageError)
|
|
186
|
+
throw error;
|
|
187
|
+
if (!this.closed && this.runs.healthy && stillCurrent())
|
|
188
|
+
this.runs.end(id, "failed", errorText(error));
|
|
189
|
+
}).catch(this.fatal).finally(async () => {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
try {
|
|
192
|
+
if (!this.runs.healthy || !["running", "waiting_input"].includes(this.runs.get(id).status))
|
|
193
|
+
await this.sessions.closeRun(id);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
this.active.delete(id);
|
|
197
|
+
}
|
|
198
|
+
}).catch(this.fatal);
|
|
199
|
+
this.active.set(id, { controller, completion, timer });
|
|
200
|
+
}
|
|
201
|
+
close() { return this.closing ??= this.stop(); }
|
|
202
|
+
async stop() {
|
|
203
|
+
this.closed = true;
|
|
204
|
+
if (this.timer)
|
|
205
|
+
clearInterval(this.timer);
|
|
206
|
+
const errors = [];
|
|
207
|
+
for (const id of this.sessions.runIds()) {
|
|
208
|
+
try {
|
|
209
|
+
this.runs.end(id, "interrupted", "Service stopped with an open ACP session");
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
errors.push(error);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const active of this.active.values()) {
|
|
216
|
+
clearTimeout(active.timer);
|
|
217
|
+
active.controller.abort(new Error("Service stopping"));
|
|
218
|
+
}
|
|
219
|
+
const stopped = await Promise.allSettled([this.sessions.close(), ...[...this.active.values()].map(active => active.completion), ...this.turns]);
|
|
220
|
+
// Rejected prompts are expected during cancellation; resource-close failures remain fatal.
|
|
221
|
+
if (stopped[0]?.status === "rejected")
|
|
222
|
+
errors.push(stopped[0].reason);
|
|
223
|
+
if (errors.length)
|
|
224
|
+
throw new AggregateError(errors, "Run supervision could not close cleanly");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=supervisor.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { absolutePath, identifier, text, timestamp } from "../shared/validation.js";
|
|
3
|
+
export const scheduleData = z.discriminatedUnion("type", [
|
|
4
|
+
z.object({ type: z.literal("now") }).strict(),
|
|
5
|
+
z.object({ type: z.literal("once"), at: timestamp }).strict(),
|
|
6
|
+
z.object({ type: z.literal("interval"), startAt: timestamp, seconds: z.number().int().min(60).max(31_536_000) }).strict(),
|
|
7
|
+
]);
|
|
8
|
+
export const nodeData = z.object({
|
|
9
|
+
id: identifier, prompt: text(64_000), executorId: identifier,
|
|
10
|
+
dependsOn: z.array(identifier).max(20), maxRunSeconds: z.number().int().min(1).max(3600),
|
|
11
|
+
}).strict();
|
|
12
|
+
export const questionData = z.object({ nodeId: identifier.optional(), question: text(4000), completedWork: text(4000) }).strict();
|
|
13
|
+
export const replyData = z.object({
|
|
14
|
+
runId: z.string().uuid(), questionId: z.string().uuid(), requestKey: identifier, answer: text(16000),
|
|
15
|
+
}).strict();
|
|
16
|
+
/** Valid internal data. No request defaults, coercion or normalization at this boundary. */
|
|
17
|
+
export const taskData = z.object({
|
|
18
|
+
requestKey: identifier, name: text(200), prompt: text(64_000), nodes: z.array(nodeData).min(1).max(20),
|
|
19
|
+
workspace: absolutePath.optional(), schedule: scheduleData, expiresAt: timestamp.optional(),
|
|
20
|
+
maxRunSeconds: z.number().int().min(1).max(86_400), maxSteps: z.number().int().min(1).max(200),
|
|
21
|
+
maxNodeTurns: z.number().int().min(1).max(10), inputWaitSeconds: z.number().int().min(1).max(604800),
|
|
22
|
+
}).strict();
|
|
23
|
+
export const taskUpdateData = z.object({ id: z.string().uuid(), action: z.enum(["pause", "resume", "cancel"]) }).strict();
|
|
24
|
+
//# sourceMappingURL=validation.js.map
|
|
@@ -14,14 +14,18 @@ function executorEditor(host, executor) {
|
|
|
14
14
|
const item = field(label, value, kind); fields[key] = item.input; card.append(item.row);
|
|
15
15
|
}
|
|
16
16
|
const approved = field("已由管理员审核原生授权,允许无人值守执行", executor.unattendedApproved, "checkbox");
|
|
17
|
-
|
|
17
|
+
let explicitlyApproved = false;
|
|
18
|
+
approved.input.addEventListener("change", () => { explicitlyApproved = approved.input.checked; });
|
|
19
|
+
for (const [key, input] of Object.entries(fields)) if (key !== "note") input.addEventListener("input", () => {
|
|
20
|
+
approved.input.checked = false; explicitlyApproved = false;
|
|
21
|
+
});
|
|
18
22
|
card.append(approved.row, element("p", "按类型从用户原生 PATH 查找 ACP 适配器。继承用户原生环境,但过滤服务私密变量;额外 ACP 审批一律拒绝。结果固定保存在 dataDir/results,只向节点提供指定上游文件,不提供整个数据目录读取。"),
|
|
19
23
|
button("从待保存配置中移除", () => { card.remove(); host.dispatchEvent(new Event("input", { bubbles: true })); }));
|
|
20
24
|
host.append(card);
|
|
21
|
-
return () => card.parentNode === host ? [{
|
|
25
|
+
return () => card.parentNode === host ? [{ explicitlyApproved, executor: {
|
|
22
26
|
id: fields.id.value, type: fields.type.value, note: fields.note.value,
|
|
23
27
|
cwd: fields.cwd.value, unattendedApproved: approved.input.checked,
|
|
24
|
-
}] : [];
|
|
28
|
+
} }] : [];
|
|
25
29
|
}
|
|
26
30
|
function coordinatorEditor(host, config) {
|
|
27
31
|
const provider = select("协调模型提供方", [["", "未配置"], ["openai", "OpenAI"], ["anthropic", "Anthropic"],
|
|
@@ -73,10 +77,13 @@ export async function renderSettings(root, navigate) {
|
|
|
73
77
|
}, state.writeBlocked));
|
|
74
78
|
const preview = element("div");
|
|
75
79
|
const save = submit(form, "校验并预览配置变更", async () => {
|
|
76
|
-
const
|
|
77
|
-
|
|
80
|
+
const entries = readers.flatMap(read => read());
|
|
81
|
+
const input = { editToken: state.editToken, approveExecutors: entries.filter(item => item.explicitlyApproved).map(item => item.executor.id),
|
|
82
|
+
config: { dataDir: startup.dataDir.input.value, port: startup.port.input.valueAsNumber, tokenEnv: startup.tokenEnv.input.value,
|
|
83
|
+
coordinator: readModel(), executors: entries.map(item => item.executor) } };
|
|
78
84
|
const checked = await request("/settings/validate", input);
|
|
79
85
|
preview.replaceChildren(block("校验后的待保存配置", checked.config),
|
|
86
|
+
block("配置变更", checked.changes),
|
|
80
87
|
block("重启后受执行端变更影响的任务", checked.affectedTasks));
|
|
81
88
|
const affected = checked.affectedTasks.map(task => task.name + " (" + task.id + ")").join("\n") || "无";
|
|
82
89
|
if (!confirm("本地校验通过,不代表模型认证或 ACP 执行已验证。\n受影响任务:\n" + affected +
|
|
@@ -121,6 +128,6 @@ export async function renderSettings(root, navigate) {
|
|
|
121
128
|
const ready = await request("/restart", {});
|
|
122
129
|
document.dispatchEvent(new CustomEvent("service-restarted", { detail: ready }));
|
|
123
130
|
}, state.writeBlocked),
|
|
124
|
-
block("
|
|
131
|
+
block("重启命令", "agent-task restart [--home /absolute/installation]"),
|
|
125
132
|
element("p", "只有新实例通过认证健康检查才显示成功。若请求结果不确定,请先运行 agent-task status;启动失败时检查当前 dataDir/service.log,不删除历史或重放任务来修复。"));
|
|
126
133
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构原则
|
|
2
2
|
|
|
3
|
-
目标是让维护者可以局部理解并安全修改系统,不为目录对称或未来假设增加机制。产品边界见[总体方案](design-proposal.md)
|
|
3
|
+
目标是让维护者可以局部理解并安全修改系统,不为目录对称或未来假设增加机制。产品边界见[总体方案](design-proposal.md),职责与资源所有权见[代码导航](technical-design.md)。
|
|
4
4
|
|
|
5
5
|
## 职责与依赖
|
|
6
6
|
|
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
| MCP/ACP、进程及操作系统协议 | 接入 |
|
|
13
13
|
| 依赖装配、运行实例及生命周期 | 应用入口 |
|
|
14
14
|
| 普通领域数据,不含业务行为或 SDK 对象 | 模型 |
|
|
15
|
-
| 展示与收集操作,通过服务执行 | Web
|
|
15
|
+
| 展示与收集操作,通过服务执行 | Web 控制台、CLI |
|
|
16
16
|
|
|
17
17
|
服务只接收普通数据和窄能力,不依赖 SDK 上下文、HTTP 对象、UI 或完整进程对象。接入实现服务所需能力,由入口装配;存储不反向依赖服务或协议。禁止循环依赖、隐藏的全局可变状态和服务定位器。
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
CLI 覆盖 Web 的全部配置和管理操作,两端必须复用同一套底层服务,不维护各自的业务规则。
|
|
20
|
+
|
|
21
|
+
按业务能力聚合模型、规则和服务,存储实现与协议接入保留明确边界。新增职责必须指定状态修改入口和资源所有者;保持关键行为测试,不加空目录、无人使用的接口、转发层或动态插件平台。实际分层和随 `npm run check` 执行的依赖检查见[代码导航](technical-design.md#依赖与一致性边界)。
|
|
20
22
|
|
|
21
23
|
## 执行与持久性
|
|
22
24
|
|
|
@@ -27,7 +29,7 @@
|
|
|
27
29
|
- 未收到结果不等于没有执行。中断或结果不明时阻止新派发,包括模型新发起的调用;不自动重试、切换 Agent 或创建新身份重放。
|
|
28
30
|
- 截止时间限制新执行开始,执行超时是另一规则。客户端断开不取消任务;取消不回滚外部操作。
|
|
29
31
|
- 资源有明确所有者,关闭幂等,关闭后拒绝操作。先停止新派发,再处理活动执行、网络与存储,最后释放所有权。
|
|
30
|
-
-
|
|
32
|
+
- 目录锁、事务和 worker 监督解决不同问题,不等于跨机一致性、无孤儿进程或工具沙箱。
|
|
31
33
|
|
|
32
34
|
## 权限与敏感数据
|
|
33
35
|
|
package/docs/cli.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# 本机管理 CLI
|
|
2
|
+
|
|
3
|
+
CLI 覆盖 Web 的配置、凭据、任务查询、未来触发控制及重启操作。两端通过同一管理 API 使用配置和任务服务;重启由同一启动器处理。命令入口见 [命令实现](../src/protocol/cli/commands.ts),管理传输见 [admin-client.ts](../src/protocol/cli/admin-client.ts)。
|
|
4
|
+
|
|
5
|
+
## 连接与命令
|
|
6
|
+
|
|
7
|
+
先按[部署说明](deployment.md)安装并启动服务。CLI 通过安装目录定位当前实例,从私有环境文件读取管理员令牌,不需要复制令牌或登录命令。使用自定义安装目录时,每条命令都传入 `--home /absolute/installation`。
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
agent-task --help
|
|
11
|
+
agent-task config --help
|
|
12
|
+
agent-task executor --help
|
|
13
|
+
agent-task coordinator --help
|
|
14
|
+
agent-task credential --help
|
|
15
|
+
agent-task task --help
|
|
16
|
+
agent-task run --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
逐级帮助是命令与参数的完整入口。管理命令支持 `--json`,结果写入 stdout,预览和诊断写入 stderr;失败退出码非零。非交互保存与任务变更须使用 `--yes`,它只确认本次操作,不替代校验或执行端授权。
|
|
20
|
+
|
|
21
|
+
服务不可用时,管理命令失败,不自动离线写文件。首次安装仍使用 `setup`;启动故障按[部署说明](deployment.md#前台重启与健康检查)停服核查。CLI 不迁移数据或修复历史。
|
|
22
|
+
|
|
23
|
+
## 配置流程
|
|
24
|
+
|
|
25
|
+
交互编辑可用 `agent-task config edit`。编辑器由 `--editor`、`VISUAL` 或 `EDITOR` 指定,值须是可执行文件名或路径,不包含 shell 参数。退出编辑器后先校验并显示影响,再确认保存。
|
|
26
|
+
|
|
27
|
+
批量编辑使用配置草稿:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
agent-task config export --output /absolute/private/draft.json
|
|
31
|
+
# 编辑草稿中的配置,保留编辑令牌。
|
|
32
|
+
agent-task config validate --file /absolute/private/draft.json
|
|
33
|
+
agent-task config apply --file /absolute/private/draft.json --yes
|
|
34
|
+
agent-task config diff
|
|
35
|
+
agent-task restart
|
|
36
|
+
agent-task config show --running
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
导出文件不包含密钥值,以私有权限创建,拒绝覆盖已有文件。编辑令牌过期、服务重启或任一客户端保存配置/凭据后,旧草稿不能提交;重新读取并人工核对,不自动合并。草稿契约见 [settings-contract.ts](../src/protocol/settings-contract.ts)。
|
|
40
|
+
|
|
41
|
+
`executor` 和 `coordinator` 提供便捷操作,配置服务负责生成候选配置,再走同一预览、校验和保存流程。新执行端默认未批准;修改 ID、类型或工作空间后清除批准。原生授权审核完成后,显式运行 `executor approve ID`。完整草稿新增或变更已批准的执行端时,也须明确声明对候选配置的授权。
|
|
42
|
+
|
|
43
|
+
配置与凭据均在下次启动生效。影响预览不修改旧任务快照,也不证明模型认证或 ACP 执行成功。保存顺序、端点与凭据隔离、重启影响见[控制台说明](web-console.md#保存与重启)。
|
|
44
|
+
|
|
45
|
+
## 凭据与任务
|
|
46
|
+
|
|
47
|
+
`credential set NAME` 使用隐藏终端输入;自动化通过 `--stdin --yes` 从可信凭据工具或受保护文件传值,不把值写进命令参数。`credential list` 只显示设置状态。执行端原生登录和权限继续由其官方工具管理。
|
|
48
|
+
|
|
49
|
+
`task show ID` 查看固定 DAG,`run show ID` 查看节点、原始回合、已接受交付及问题回复。`task pause/resume/cancel` 仅控制未来触发,不停止当前运行;恢复不补跑,取消后不可恢复未来触发;有不确定外部结果时也拒绝恢复。操作边界与 Web 一致,见[控制台说明](web-console.md#操作范围)。
|