@tea-agent/loop-agent 0.20.1-beta.0 → 0.20.1
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/CHANGELOG.md +24 -0
- package/dist/application/dag/args.js +29 -0
- package/dist/application/dag/run-dag.js +3 -1
- package/dist/cli/command-definitions.js +15 -1
- package/dist/cli/program.js +11 -1
- package/dist/commands/dag-rerun-task.js +19 -0
- package/dist/commands/dag-rerun.js +111 -0
- package/dist/shared/operator/capabilities.js +54 -0
- package/dist/worker/console/index.js +1 -1
- package/dist/worker/console/inspect-split.js +82 -0
- package/dist/worker/console/operation-runner.js +3 -1
- package/dist/worker/console/operation-store.js +1 -0
- package/dist/worker/console/operator-actions.js +153 -2
- package/dist/worker/console/operator-user-error.js +10 -0
- package/dist/worker/console/pi-readiness.js +4 -0
- package/dist/worker/console/recovery-cta.js +116 -5
- package/dist/worker/console/recovery-selection.js +107 -0
- package/dist/worker/console/resolve-dag-run-for-task.js +50 -11
- package/dist/worker/console/routes.js +20 -0
- package/dist/worker/console/sibling-controller.js +12 -7
- package/dist/worker/console/static/assets/index-CUDke82y.js +18 -0
- package/dist/worker/console/static/assets/index-wSEksVSO.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/observability/read-model.js +60 -0
- package/dist/worker/observe/static/index.html +1 -1
- package/dist/worker/run-task/run-task.js +7 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +64 -0
- package/dist/workflows/dag/init-hybrid.js +90 -48
- package/dist/workflows/dag/node-execution.js +40 -6
- package/dist/workflows/dag/output-protocol.js +76 -0
- package/dist/workflows/dag/rerun-plan.js +611 -0
- package/dist/workflows/dag/rerun-run.js +497 -0
- package/dist/workflows/dag/rerun-task.js +284 -0
- package/dist/workflows/dag/retry-policy.js +20 -1
- package/dist/workflows/dag/runner.js +50 -0
- package/dist/workflows/dag/skill-snapshot.js +22 -3
- package/dist/workflows/dag/types.js +7 -0
- package/dist/workflows/dag/validate.js +11 -0
- package/dist/workflows/dag/workspace-checkpoint.js +163 -0
- package/docs/README.md +1 -0
- package/docs/templates/agent-dag.schema.json +17 -2
- package/docs/templates/frontend-test-case-checklist.md +16 -1
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +26 -2
- package/docs/templates/frontend-test-dag.json +65 -6
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +4 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +3 -0
- package/skills/playwright-cli-case-generator/SKILL.md +35 -7
- package/dist/worker/console/static/assets/index-3vsjZJHq.js +0 -16
- package/dist/worker/console/static/assets/index-i1wV4LrY.css +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { access, readFile } from "node:fs/promises";
|
|
2
2
|
import { buildOperatorCapabilitiesDocument } from "../../shared/operator/capabilities.js";
|
|
3
|
-
import { operatorFailed, operatorSucceeded, } from "../../shared/operator/envelope.js";
|
|
3
|
+
import { buildOperatorResult, operatorFailed, operatorSucceeded, } from "../../shared/operator/envelope.js";
|
|
4
4
|
import { getTaskPaths } from "../../task/runtime.js";
|
|
5
5
|
import { stageUtf8Text } from "./app-data.js";
|
|
6
6
|
import { ALL_CONFIRMATION_CHALLENGES, hashDagBytes, } from "./dag-confirmation.js";
|
|
@@ -15,6 +15,7 @@ import { applyGrillMeAnswer, draftSummary, emptyDraft, isDraftStructurallyComple
|
|
|
15
15
|
import { toCanonicalDraftForCli, } from "./draft-store.js";
|
|
16
16
|
import { normalizeConsoleWorkflowKind } from "./workflow-kinds.js";
|
|
17
17
|
import { deriveTaskIdentityFromPrd, nextTaskIdRevision, } from "./prd-identity.js";
|
|
18
|
+
import { resolveSiblingAgentWorkerBin } from "./sibling-controller.js";
|
|
18
19
|
const MUTATION_OR_LONG = new Set([
|
|
19
20
|
"newTask",
|
|
20
21
|
"importPrd",
|
|
@@ -23,6 +24,9 @@ const MUTATION_OR_LONG = new Set([
|
|
|
23
24
|
"contractRecover",
|
|
24
25
|
"dagRunTask",
|
|
25
26
|
"runDag",
|
|
27
|
+
"dagRerun",
|
|
28
|
+
"standaloneTaskRerun",
|
|
29
|
+
"workerTaskRetry",
|
|
26
30
|
]);
|
|
27
31
|
function paramsOf(req) {
|
|
28
32
|
return (req.actionParams ?? {});
|
|
@@ -39,10 +43,40 @@ async function runReadCli(ctx, args, command) {
|
|
|
39
43
|
expectJson: true,
|
|
40
44
|
});
|
|
41
45
|
if (result.json && typeof result.json === "object") {
|
|
42
|
-
const
|
|
46
|
+
const raw = result.json;
|
|
47
|
+
const env = raw;
|
|
43
48
|
if (env.schemaVersion === 1 && typeof env.ok === "boolean") {
|
|
44
49
|
return env;
|
|
45
50
|
}
|
|
51
|
+
if (command === "dag rerun plan" && raw.plan && typeof raw.plan === "object") {
|
|
52
|
+
const plan = raw.plan;
|
|
53
|
+
if (raw.ok === true) {
|
|
54
|
+
return operatorSucceeded(command, plan);
|
|
55
|
+
}
|
|
56
|
+
const blockedReasons = Array.isArray(plan.blockedReasons)
|
|
57
|
+
? plan.blockedReasons.map(String)
|
|
58
|
+
: [];
|
|
59
|
+
const blockingNodes = Array.isArray(plan.blockingNodes)
|
|
60
|
+
? plan.blockingNodes.map((n) => [n.nodeId, n.reasonCode].filter(Boolean).join(":"))
|
|
61
|
+
: [];
|
|
62
|
+
const reasons = [...blockedReasons, ...blockingNodes].filter(Boolean);
|
|
63
|
+
return buildOperatorResult({
|
|
64
|
+
command,
|
|
65
|
+
ok: false,
|
|
66
|
+
outcome: "rejected",
|
|
67
|
+
result: plan,
|
|
68
|
+
error: {
|
|
69
|
+
code: "DAG_RERUN_INELIGIBLE",
|
|
70
|
+
message: reasons.length > 0
|
|
71
|
+
? `从节点重跑不具备安全资格:${reasons.slice(0, 6).join("; ")}${reasons.length > 6 ? "…" : ""}`
|
|
72
|
+
: "从节点重跑不具备安全资格(eligible=false)",
|
|
73
|
+
details: {
|
|
74
|
+
eligible: false,
|
|
75
|
+
planHash: typeof plan.planHash === "string" ? plan.planHash : undefined,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
46
80
|
}
|
|
47
81
|
if (result.ok && result.exitCode === 0) {
|
|
48
82
|
return operatorSucceeded(command, {
|
|
@@ -73,6 +107,7 @@ async function acceptOperation(ctx, input) {
|
|
|
73
107
|
actionParams: input.actionParams,
|
|
74
108
|
taskId: input.taskId,
|
|
75
109
|
cliArgs: input.cliArgs,
|
|
110
|
+
...(input.externalCommand ? { externalCommand: input.externalCommand } : {}),
|
|
76
111
|
});
|
|
77
112
|
if (created.kind === "conflict") {
|
|
78
113
|
return {
|
|
@@ -165,6 +200,14 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
165
200
|
const body = await runReadCli(ctx, ["doctor", "--json"], "doctor");
|
|
166
201
|
return { kind: "sync", status: body.ok ? 200 : 400, body };
|
|
167
202
|
}
|
|
203
|
+
case "dagReport": {
|
|
204
|
+
const runId = str(p.runId) ?? str(p.dagRunId);
|
|
205
|
+
if (!runId) {
|
|
206
|
+
return invalid(action, "runId is required");
|
|
207
|
+
}
|
|
208
|
+
const body = await runReadCli(ctx, ["dag", "report", "--run-id", runId, "--json"], "dag report");
|
|
209
|
+
return { kind: "sync", status: body.ok ? 200 : 400, body };
|
|
210
|
+
}
|
|
168
211
|
case "status": {
|
|
169
212
|
const taskId = str(p.taskId);
|
|
170
213
|
if (!taskId) {
|
|
@@ -894,6 +937,114 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
894
937
|
],
|
|
895
938
|
});
|
|
896
939
|
}
|
|
940
|
+
case "dagRerunPlan": {
|
|
941
|
+
const runId = str(p.runId);
|
|
942
|
+
const fromNode = str(p.fromNode);
|
|
943
|
+
if (!runId || !fromNode) {
|
|
944
|
+
return invalid(action, "runId and fromNode are required");
|
|
945
|
+
}
|
|
946
|
+
const body = await runReadCli(ctx, [
|
|
947
|
+
"dag",
|
|
948
|
+
"rerun",
|
|
949
|
+
"--run-id",
|
|
950
|
+
runId,
|
|
951
|
+
"--from-node",
|
|
952
|
+
fromNode,
|
|
953
|
+
"--plan",
|
|
954
|
+
"--json",
|
|
955
|
+
], "dag rerun plan");
|
|
956
|
+
return { kind: "sync", status: body.ok ? 200 : 400, body };
|
|
957
|
+
}
|
|
958
|
+
case "dagRerun": {
|
|
959
|
+
const runId = str(p.runId);
|
|
960
|
+
const fromNode = str(p.fromNode);
|
|
961
|
+
const planHash = str(p.planHash);
|
|
962
|
+
const reason = str(p.reason);
|
|
963
|
+
const clientRequestId = str(req.clientRequestId);
|
|
964
|
+
if (!runId || !fromNode || !planHash || !reason || !clientRequestId) {
|
|
965
|
+
return invalid(action, "runId, fromNode, planHash, reason, and clientRequestId are required");
|
|
966
|
+
}
|
|
967
|
+
return acceptOperation(ctx, {
|
|
968
|
+
action,
|
|
969
|
+
actionParams: p,
|
|
970
|
+
clientRequestId,
|
|
971
|
+
cliArgs: [
|
|
972
|
+
"dag",
|
|
973
|
+
"rerun",
|
|
974
|
+
"--run-id",
|
|
975
|
+
runId,
|
|
976
|
+
"--from-node",
|
|
977
|
+
fromNode,
|
|
978
|
+
"--plan-hash",
|
|
979
|
+
planHash,
|
|
980
|
+
"--request-id",
|
|
981
|
+
clientRequestId,
|
|
982
|
+
"--reason",
|
|
983
|
+
reason,
|
|
984
|
+
"--json",
|
|
985
|
+
],
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
case "standaloneTaskRerun": {
|
|
989
|
+
const runId = str(p.runId);
|
|
990
|
+
const reason = str(p.reason);
|
|
991
|
+
const clientRequestId = str(req.clientRequestId);
|
|
992
|
+
if (!runId || !reason || !clientRequestId) {
|
|
993
|
+
return invalid(action, "runId, reason, and clientRequestId are required");
|
|
994
|
+
}
|
|
995
|
+
const cliArgs = [
|
|
996
|
+
"dag",
|
|
997
|
+
"rerun-task",
|
|
998
|
+
"--run-id",
|
|
999
|
+
runId,
|
|
1000
|
+
"--reason",
|
|
1001
|
+
reason,
|
|
1002
|
+
"--request-id",
|
|
1003
|
+
clientRequestId,
|
|
1004
|
+
"--json",
|
|
1005
|
+
];
|
|
1006
|
+
const profile = str(p.profile);
|
|
1007
|
+
const taskId = str(p.taskId);
|
|
1008
|
+
if (profile)
|
|
1009
|
+
cliArgs.push("--profile", profile);
|
|
1010
|
+
if (taskId)
|
|
1011
|
+
cliArgs.push("--task-id", taskId);
|
|
1012
|
+
return acceptOperation(ctx, {
|
|
1013
|
+
action,
|
|
1014
|
+
actionParams: p,
|
|
1015
|
+
clientRequestId,
|
|
1016
|
+
taskId,
|
|
1017
|
+
cliArgs,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
case "workerTaskRetry": {
|
|
1021
|
+
const taskId = str(p.taskId);
|
|
1022
|
+
const featureId = str(p.featureId);
|
|
1023
|
+
const reason = str(p.reason);
|
|
1024
|
+
const clientRequestId = str(req.clientRequestId);
|
|
1025
|
+
if (!taskId || !reason || !clientRequestId) {
|
|
1026
|
+
return invalid(action, "taskId, reason, and clientRequestId are required");
|
|
1027
|
+
}
|
|
1028
|
+
const args = [
|
|
1029
|
+
"task",
|
|
1030
|
+
"retry",
|
|
1031
|
+
taskId,
|
|
1032
|
+
"--repo",
|
|
1033
|
+
ctx.repoRoot,
|
|
1034
|
+
"--reason",
|
|
1035
|
+
reason,
|
|
1036
|
+
];
|
|
1037
|
+
if (featureId)
|
|
1038
|
+
args.push("--feature-id", featureId);
|
|
1039
|
+
return acceptOperation(ctx, {
|
|
1040
|
+
action,
|
|
1041
|
+
actionParams: p,
|
|
1042
|
+
clientRequestId,
|
|
1043
|
+
taskId,
|
|
1044
|
+
cliArgs: args,
|
|
1045
|
+
externalCommand: resolveSiblingAgentWorkerBin(),
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
897
1048
|
default:
|
|
898
1049
|
return {
|
|
899
1050
|
kind: "error",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Map internal spawn/controller errors to actionable Chinese copy. */
|
|
2
|
+
export function formatOperatorUserError(message) {
|
|
3
|
+
const raw = (message ?? "").trim();
|
|
4
|
+
if (!raw)
|
|
5
|
+
return "操作失败";
|
|
6
|
+
if (/controller identity changed|package fingerprint changed|entry binary sha256 changed/i.test(raw)) {
|
|
7
|
+
return "控制器版本已变更(Console 启动后代码被重新编译)。请重启 Console 后再试,一次会话中途不要升级/重建控制器。";
|
|
8
|
+
}
|
|
9
|
+
return raw;
|
|
10
|
+
}
|
|
@@ -11,6 +11,8 @@ const SETUP_BLOCKED = Object.freeze([
|
|
|
11
11
|
"contractAdopt",
|
|
12
12
|
"dagRunTask",
|
|
13
13
|
"runDag",
|
|
14
|
+
"dagRerun",
|
|
15
|
+
"standaloneTaskRerun",
|
|
14
16
|
"prepareDagConfirmation",
|
|
15
17
|
"interviewStart",
|
|
16
18
|
"interviewTurn",
|
|
@@ -21,6 +23,8 @@ const DEGRADED_BLOCKED = Object.freeze([
|
|
|
21
23
|
"contractAdopt",
|
|
22
24
|
"dagRunTask",
|
|
23
25
|
"runDag",
|
|
26
|
+
"dagRerun",
|
|
27
|
+
"standaloneTaskRerun",
|
|
24
28
|
// new Interview may resume; brand-new apply/run still blocked
|
|
25
29
|
]);
|
|
26
30
|
/** Default Interview / DAG model matrix (provider, modelId). */
|
|
@@ -6,51 +6,106 @@ const CTA = {
|
|
|
6
6
|
report: {
|
|
7
7
|
id: "report",
|
|
8
8
|
label: "查看报告",
|
|
9
|
-
action: "
|
|
10
|
-
|
|
9
|
+
action: "dagReport",
|
|
10
|
+
lane: "dag",
|
|
11
|
+
guide: "看失败分类与建议动作(交接报告摘要)",
|
|
12
|
+
commandHint: "loop-agent dag report --run-id <run-id> --json",
|
|
11
13
|
},
|
|
12
14
|
doctor: {
|
|
13
15
|
id: "doctor",
|
|
14
16
|
label: "Doctor 诊断",
|
|
15
17
|
action: "doctor",
|
|
18
|
+
lane: "dag",
|
|
19
|
+
guide: "只读诊断:检查 Console / Pi / 仓库就绪,不是重跑前置",
|
|
16
20
|
commandHint: "agent-worker console doctor --repo .",
|
|
17
21
|
},
|
|
18
22
|
decision: {
|
|
19
23
|
id: "decision",
|
|
20
24
|
label: "人工决策",
|
|
25
|
+
lane: "dag",
|
|
26
|
+
guide: "暂停等人时:先看 doctor 输出再做决策(经 CLI)",
|
|
21
27
|
commandHint: "loop-agent dag doctor --run-id <run-id> --markdown",
|
|
22
28
|
},
|
|
23
29
|
resume: {
|
|
24
30
|
id: "resume",
|
|
25
31
|
label: "恢复运行",
|
|
32
|
+
lane: "dag",
|
|
33
|
+
guide: "仅当运行可安全 resume 时使用(经 CLI)",
|
|
26
34
|
commandHint: "loop-agent run-dag --resume --run-id <run-id>",
|
|
27
35
|
},
|
|
28
36
|
reconcile: {
|
|
29
37
|
id: "reconcile",
|
|
30
38
|
label: "对账 / reconcile",
|
|
31
39
|
requiresReason: true,
|
|
40
|
+
lane: "dag",
|
|
41
|
+
guide: "状态不一致时先对账,再决定是否重跑(需原因)",
|
|
32
42
|
commandHint: "loop-agent dag reconcile-run --run-id <run-id> --reason <structured-reason>",
|
|
33
43
|
},
|
|
34
44
|
regenerate: {
|
|
35
45
|
id: "regenerate",
|
|
36
46
|
label: "重新生成契约/计划",
|
|
47
|
+
lane: "dag",
|
|
48
|
+
guide: "契约/计划过期时:回任务页或经 CLI 重新 run-task(本页仅提示)",
|
|
37
49
|
commandHint: "loop-agent dag run-task <task-id> --profile auto",
|
|
38
50
|
},
|
|
39
51
|
observeLink: {
|
|
40
52
|
id: "observeLink",
|
|
41
|
-
label: "
|
|
53
|
+
label: "全屏观测",
|
|
42
54
|
action: "observeLink",
|
|
55
|
+
lane: "dag",
|
|
56
|
+
guide: "打开完整 Inspect 画布;顶栏可返回操作台",
|
|
43
57
|
commandHint: "agent-worker console serve --repo . --port 8790",
|
|
44
58
|
},
|
|
59
|
+
dagRerun: {
|
|
60
|
+
id: "dagRerun",
|
|
61
|
+
label: "从节点重跑",
|
|
62
|
+
requiresReason: true,
|
|
63
|
+
action: "dagRerun",
|
|
64
|
+
lane: "dag",
|
|
65
|
+
guide: "子图重跑:需选起点且 plan 合格;含 writer 的早期节点常不可用",
|
|
66
|
+
commandHint: "loop-agent dag rerun --run-id <run-id> --from-node <node-id> --plan",
|
|
67
|
+
},
|
|
68
|
+
standaloneTaskRerun: {
|
|
69
|
+
id: "standaloneTaskRerun",
|
|
70
|
+
label: "完整重跑任务",
|
|
71
|
+
requiresReason: true,
|
|
72
|
+
action: "standaloneTaskRerun",
|
|
73
|
+
lane: "dag",
|
|
74
|
+
guide: "整单重跑:失败类默认首选;需确认原因后提交",
|
|
75
|
+
commandHint: "loop-agent dag rerun-task --run-id <run-id> --reason <reason> --request-id <key>",
|
|
76
|
+
},
|
|
77
|
+
workerTaskRetry: {
|
|
78
|
+
id: "workerTaskRetry",
|
|
79
|
+
label: "重新排队 Worker Task",
|
|
80
|
+
requiresReason: true,
|
|
81
|
+
action: "workerTaskRetry",
|
|
82
|
+
lane: "worker",
|
|
83
|
+
guide: "仅 Worker 队列任务失败时使用,不要与 DAG 重跑混用",
|
|
84
|
+
commandHint: "agent-worker task retry <task-id> --feature-id <feature-id> --repo . --reason <reason>",
|
|
85
|
+
},
|
|
45
86
|
};
|
|
46
87
|
const MATRIX = {
|
|
47
88
|
"validation-failed": ["report", "doctor", "regenerate"],
|
|
48
89
|
"contract-drift": ["report", "doctor", "decision", "reconcile", "regenerate"],
|
|
49
90
|
"runtime-mismatch": ["report", "doctor", "reconcile", "regenerate"],
|
|
50
91
|
"pause-on-human": ["decision", "resume", "report", "doctor"],
|
|
51
|
-
"verification-failed": [
|
|
92
|
+
"verification-failed": [
|
|
93
|
+
"report",
|
|
94
|
+
"doctor",
|
|
95
|
+
"standaloneTaskRerun",
|
|
96
|
+
"dagRerun",
|
|
97
|
+
"resume",
|
|
98
|
+
"regenerate",
|
|
99
|
+
],
|
|
52
100
|
"orphan-unknown": ["report", "doctor", "reconcile"],
|
|
53
|
-
"terminal-failed": [
|
|
101
|
+
"terminal-failed": [
|
|
102
|
+
"report",
|
|
103
|
+
"doctor",
|
|
104
|
+
"dagRerun",
|
|
105
|
+
"standaloneTaskRerun",
|
|
106
|
+
"workerTaskRetry",
|
|
107
|
+
"regenerate",
|
|
108
|
+
],
|
|
54
109
|
"terminal-succeeded": ["report", "observeLink"],
|
|
55
110
|
"needs-reconcile": ["report", "doctor", "reconcile", "regenerate"],
|
|
56
111
|
};
|
|
@@ -68,6 +123,62 @@ export function allowedRecoveryCtas(factClass) {
|
|
|
68
123
|
const ids = MATRIX[factClass] ?? MATRIX["orphan-unknown"];
|
|
69
124
|
return ids.map((id) => CTA[id]);
|
|
70
125
|
}
|
|
126
|
+
/** Split CTAs so Console UI never mixes DAG runtime and Worker Task lanes. */
|
|
127
|
+
export function partitionRecoveryCtas(ctas) {
|
|
128
|
+
const dag = [];
|
|
129
|
+
const worker = [];
|
|
130
|
+
for (const cta of ctas) {
|
|
131
|
+
if (cta.lane === "worker")
|
|
132
|
+
worker.push(cta);
|
|
133
|
+
else
|
|
134
|
+
dag.push(cta);
|
|
135
|
+
}
|
|
136
|
+
return { dag, worker };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Console only shows CTAs that actually dispatch an operator action.
|
|
140
|
+
* Pure CLI-hint rows (regenerate / decision / resume / reconcile) stay in the
|
|
141
|
+
* policy matrix for docs, but must not appear as dead buttons.
|
|
142
|
+
*/
|
|
143
|
+
export function isConsoleExecutableRecoveryCta(cta) {
|
|
144
|
+
return typeof cta.action === "string" && cta.action.trim().length > 0;
|
|
145
|
+
}
|
|
146
|
+
/** Matrix CTAs that the Official Console may render as buttons. */
|
|
147
|
+
export function consoleRecoveryCtas(factClass) {
|
|
148
|
+
return allowedRecoveryCtas(factClass).filter(isConsoleExecutableRecoveryCta);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Suggested primary CTA for the current fact class.
|
|
152
|
+
* Prefer safe whole-task recovery over node rerun when early writers often block.
|
|
153
|
+
*/
|
|
154
|
+
export function recommendedRecoveryCtaId(factClass) {
|
|
155
|
+
const allowed = new Set(consoleRecoveryCtas(factClass).map((c) => c.id));
|
|
156
|
+
const pick = (...ids) => {
|
|
157
|
+
for (const id of ids) {
|
|
158
|
+
if (allowed.has(id))
|
|
159
|
+
return id;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
};
|
|
163
|
+
switch (factClass) {
|
|
164
|
+
case "terminal-failed":
|
|
165
|
+
case "verification-failed":
|
|
166
|
+
return pick("standaloneTaskRerun", "dagRerun", "doctor", "report");
|
|
167
|
+
case "pause-on-human":
|
|
168
|
+
return pick("doctor", "report");
|
|
169
|
+
case "needs-reconcile":
|
|
170
|
+
case "runtime-mismatch":
|
|
171
|
+
case "contract-drift":
|
|
172
|
+
return pick("doctor", "report");
|
|
173
|
+
case "validation-failed":
|
|
174
|
+
return pick("doctor", "report");
|
|
175
|
+
case "terminal-succeeded":
|
|
176
|
+
return pick("observeLink", "report");
|
|
177
|
+
case "orphan-unknown":
|
|
178
|
+
default:
|
|
179
|
+
return pick("doctor", "report");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
71
182
|
/**
|
|
72
183
|
* Negative product invariants for recovery matrix (test + docs).
|
|
73
184
|
*/
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for Operator Console recovery pickers (run + from-node).
|
|
3
|
+
*/
|
|
4
|
+
const FAILED_NODE = new Set(["failed", "error", "partial_failed"]);
|
|
5
|
+
const ATTENTION_NODE = new Set(["blocked", "paused", "awaiting_human"]);
|
|
6
|
+
function norm(value) {
|
|
7
|
+
return (value ?? "").trim().toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
export function isFailedNodeStatus(status) {
|
|
10
|
+
return FAILED_NODE.has(norm(status));
|
|
11
|
+
}
|
|
12
|
+
function runAttentionRank(run) {
|
|
13
|
+
const status = norm(run.status);
|
|
14
|
+
if (FAILED_NODE.has(status) || status.includes("fail"))
|
|
15
|
+
return 0;
|
|
16
|
+
if (run.lifecycle === "paused" || status === "paused")
|
|
17
|
+
return 1;
|
|
18
|
+
if (run.lifecycle === "active")
|
|
19
|
+
return 2;
|
|
20
|
+
return 3;
|
|
21
|
+
}
|
|
22
|
+
/** Prefer failed → paused → active → completed; newest mtime within tier. */
|
|
23
|
+
export function sortRecoveryRunCandidates(runs) {
|
|
24
|
+
return [...runs].sort((a, b) => {
|
|
25
|
+
const rank = runAttentionRank(a) - runAttentionRank(b);
|
|
26
|
+
if (rank !== 0)
|
|
27
|
+
return rank;
|
|
28
|
+
return b.mtimeMs - a.mtimeMs;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export function pickDefaultRecoveryRunId(runs) {
|
|
32
|
+
const sorted = sortRecoveryRunCandidates(runs);
|
|
33
|
+
return sorted[0]?.dagRunId;
|
|
34
|
+
}
|
|
35
|
+
function nodeAttentionRank(node) {
|
|
36
|
+
const status = norm(node.status);
|
|
37
|
+
if (FAILED_NODE.has(status))
|
|
38
|
+
return 0;
|
|
39
|
+
if (ATTENTION_NODE.has(status))
|
|
40
|
+
return 1;
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
/** Prefer failed nodes, then attention states, then stable id order. */
|
|
44
|
+
export function sortRecoveryNodeCandidates(nodes) {
|
|
45
|
+
return [...nodes].sort((a, b) => {
|
|
46
|
+
const rank = nodeAttentionRank(a) - nodeAttentionRank(b);
|
|
47
|
+
if (rank !== 0)
|
|
48
|
+
return rank;
|
|
49
|
+
return a.nodeId.localeCompare(b.nodeId);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** Default from-node: first failed (or attention) node; undefined if none. */
|
|
53
|
+
export function pickDefaultRecoveryNodeId(nodes) {
|
|
54
|
+
const sorted = sortRecoveryNodeCandidates(nodes);
|
|
55
|
+
const preferred = sorted.find((n) => isFailedNodeStatus(n.status) || ATTENTION_NODE.has(norm(n.status)));
|
|
56
|
+
return preferred?.nodeId ?? sorted[0]?.nodeId;
|
|
57
|
+
}
|
|
58
|
+
export function formatRecoveryRunLabel(run) {
|
|
59
|
+
const status = run.status?.trim() || run.lifecycle;
|
|
60
|
+
const shortId = run.dagRunId.length > 36
|
|
61
|
+
? `${run.dagRunId.slice(0, 18)}…${run.dagRunId.slice(-8)}`
|
|
62
|
+
: run.dagRunId;
|
|
63
|
+
return `${shortId} · ${status}`;
|
|
64
|
+
}
|
|
65
|
+
export function formatRecoveryNodeLabel(node) {
|
|
66
|
+
const status = node.status?.trim() || "unknown";
|
|
67
|
+
const name = node.label?.trim();
|
|
68
|
+
return name && name !== node.nodeId
|
|
69
|
+
? `${node.nodeId} · ${status} · ${name}`
|
|
70
|
+
: `${node.nodeId} · ${status}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Structured recovery reason for Console confirm dialog.
|
|
74
|
+
* Prefer selected run/node failure facts over stale operator errors.
|
|
75
|
+
*/
|
|
76
|
+
export function buildRecoveryReason(input) {
|
|
77
|
+
const action = input.actionLabel?.trim() || "恢复";
|
|
78
|
+
const lines = [`operator-recovery: ${action}`];
|
|
79
|
+
const runId = input.dagRunId?.trim();
|
|
80
|
+
if (runId) {
|
|
81
|
+
const runStatus = input.runStatus?.trim();
|
|
82
|
+
lines.push(runStatus ? `run=${runId} (${runStatus})` : `run=${runId}`);
|
|
83
|
+
}
|
|
84
|
+
const nodeId = input.fromNodeId?.trim();
|
|
85
|
+
if (nodeId) {
|
|
86
|
+
const nodeStatus = input.nodeStatus?.trim();
|
|
87
|
+
lines.push(nodeStatus ? `from-node=${nodeId} (${nodeStatus})` : `from-node=${nodeId}`);
|
|
88
|
+
}
|
|
89
|
+
const fact = input.factClassLabel?.trim();
|
|
90
|
+
if (fact)
|
|
91
|
+
lines.push(`fact=${fact}`);
|
|
92
|
+
const detail = input.nodeError?.trim() ||
|
|
93
|
+
input.errorMessage?.trim() ||
|
|
94
|
+
(input.errorCode?.trim() ? `code=${input.errorCode.trim()}` : "");
|
|
95
|
+
if (detail)
|
|
96
|
+
lines.push(`detail=${detail.replace(/\s+/g, " ").slice(0, 280)}`);
|
|
97
|
+
return lines.join("\n").slice(0, 500);
|
|
98
|
+
}
|
|
99
|
+
/** True when reason looks like an auto-generated template (safe to refresh). */
|
|
100
|
+
export function isAutoRecoveryReason(value) {
|
|
101
|
+
const trimmed = value.trim();
|
|
102
|
+
if (!trimmed)
|
|
103
|
+
return true;
|
|
104
|
+
return (trimmed === "operator-recovery" ||
|
|
105
|
+
trimmed.startsWith("operator-recovery:") ||
|
|
106
|
+
trimmed.startsWith("recovery:"));
|
|
107
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve
|
|
3
|
-
* Used by Console observeLink
|
|
2
|
+
* Resolve / list DAG run ids for a harness task under `.harness/dag-runs`.
|
|
3
|
+
* Used by Console observeLink and Recovery candidates API.
|
|
4
4
|
*/
|
|
5
5
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { sortRecoveryRunCandidates, } from "./recovery-selection.js";
|
|
7
8
|
const LIFECYCLE_ORDER = ["active", "paused", "completed"];
|
|
8
9
|
async function readTaskIdFromRunDir(runDir) {
|
|
9
10
|
const runJsonPath = path.join(runDir, "run.json");
|
|
@@ -23,13 +24,20 @@ async function readTaskIdFromRunDir(runDir) {
|
|
|
23
24
|
}
|
|
24
25
|
return undefined;
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
async function readRunMeta(runDir) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = JSON.parse(await readFile(path.join(runDir, "state.json"), "utf8"));
|
|
30
|
+
return {
|
|
31
|
+
status: typeof raw.status === "string" ? raw.status : undefined,
|
|
32
|
+
title: typeof raw.title === "string" ? raw.title : undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function scanDagRunCandidates(repoRoot, taskIdFilter) {
|
|
40
|
+
const wanted = taskIdFilter?.trim() || undefined;
|
|
33
41
|
const matches = [];
|
|
34
42
|
for (const lifecycle of LIFECYCLE_ORDER) {
|
|
35
43
|
const lifecycleDir = path.join(repoRoot, ".harness", "dag-runs", lifecycle);
|
|
@@ -54,15 +62,29 @@ export async function resolveLatestDagRunIdForTask(repoRoot, taskId) {
|
|
|
54
62
|
if (!st.isDirectory())
|
|
55
63
|
continue;
|
|
56
64
|
const boundTaskId = await readTaskIdFromRunDir(runDir);
|
|
57
|
-
if (boundTaskId !== wanted)
|
|
65
|
+
if (wanted && boundTaskId !== wanted)
|
|
58
66
|
continue;
|
|
67
|
+
const meta = await readRunMeta(runDir);
|
|
59
68
|
matches.push({
|
|
60
69
|
dagRunId: name,
|
|
61
70
|
lifecycle,
|
|
62
71
|
mtimeMs: st.mtimeMs,
|
|
72
|
+
status: meta.status,
|
|
73
|
+
title: meta.title,
|
|
74
|
+
...(boundTaskId ? { taskId: boundTaskId } : {}),
|
|
63
75
|
});
|
|
64
76
|
}
|
|
65
77
|
}
|
|
78
|
+
return matches;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Prefer active/paused over completed; within a lifecycle, newest mtime wins.
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveLatestDagRunIdForTask(repoRoot, taskId) {
|
|
84
|
+
const wanted = taskId.trim();
|
|
85
|
+
if (!wanted)
|
|
86
|
+
return undefined;
|
|
87
|
+
const matches = await scanDagRunCandidates(repoRoot, wanted);
|
|
66
88
|
if (matches.length === 0)
|
|
67
89
|
return undefined;
|
|
68
90
|
const lifecycleRank = (value) => LIFECYCLE_ORDER.indexOf(value);
|
|
@@ -72,5 +94,22 @@ export async function resolveLatestDagRunIdForTask(repoRoot, taskId) {
|
|
|
72
94
|
return life;
|
|
73
95
|
return b.mtimeMs - a.mtimeMs;
|
|
74
96
|
});
|
|
75
|
-
|
|
97
|
+
const top = matches[0];
|
|
98
|
+
return {
|
|
99
|
+
dagRunId: top.dagRunId,
|
|
100
|
+
lifecycle: top.lifecycle,
|
|
101
|
+
mtimeMs: top.mtimeMs,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* List DAG runs for Recovery pickers.
|
|
106
|
+
* - With taskId: only runs bound to that task
|
|
107
|
+
* - Without taskId: recent runs across lifecycles (capped)
|
|
108
|
+
* Sorted for recovery attention (failed/paused first).
|
|
109
|
+
*/
|
|
110
|
+
export async function listDagRunsForRecovery(repoRoot, options) {
|
|
111
|
+
const taskId = options?.taskId?.trim() || undefined;
|
|
112
|
+
const limit = Math.min(Math.max(options?.limit ?? 40, 1), 100);
|
|
113
|
+
const matches = await scanDagRunCandidates(repoRoot, taskId);
|
|
114
|
+
return sortRecoveryRunCandidates(matches).slice(0, limit);
|
|
76
115
|
}
|
|
@@ -6,6 +6,7 @@ import { openSseResponse, parseLastEventId, writeSseEvent, } from "./operation-s
|
|
|
6
6
|
import { buildOperatorSurfaceHealthV1 } from "./operator-surface-health.js";
|
|
7
7
|
import { ROUTES } from "../observe/routes.js";
|
|
8
8
|
import { evaluateMutationGate, injectConfirmationTokenScript, isMutationMethod, } from "./security.js";
|
|
9
|
+
import { listDagRunsForRecovery } from "./resolve-dag-run-for-task.js";
|
|
9
10
|
export function sendJson(res, status, body, extraHeaders) {
|
|
10
11
|
const payload = JSON.stringify(body);
|
|
11
12
|
res.writeHead(status, {
|
|
@@ -98,6 +99,21 @@ async function handleReadiness(_req, res, ctx) {
|
|
|
98
99
|
}
|
|
99
100
|
sendJson(res, 200, report);
|
|
100
101
|
}
|
|
102
|
+
async function handleRecoveryCandidates(_req, res, ctx, url) {
|
|
103
|
+
const taskId = url.searchParams.get("taskId")?.trim() || undefined;
|
|
104
|
+
const limitRaw = url.searchParams.get("limit");
|
|
105
|
+
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
|
|
106
|
+
const runs = await listDagRunsForRecovery(ctx.repoRoot, {
|
|
107
|
+
taskId,
|
|
108
|
+
...(Number.isFinite(limit) ? { limit } : {}),
|
|
109
|
+
});
|
|
110
|
+
sendJson(res, 200, {
|
|
111
|
+
ok: true,
|
|
112
|
+
schemaVersion: 1,
|
|
113
|
+
taskId: taskId ?? null,
|
|
114
|
+
runs,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
101
117
|
async function handleSessionPing(req, res, ctx) {
|
|
102
118
|
const failure = evaluateMutationGate(req, {
|
|
103
119
|
bootToken: ctx.bootToken.value,
|
|
@@ -328,6 +344,10 @@ export async function handleConsoleOperatorRequest(req, res, ctx) {
|
|
|
328
344
|
await handleReadiness(req, res, ctx);
|
|
329
345
|
return true;
|
|
330
346
|
}
|
|
347
|
+
if (method === "GET" && pathname === "/api/operator/v1/recovery/candidates") {
|
|
348
|
+
await handleRecoveryCandidates(req, res, ctx, url);
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
331
351
|
// Phase 1 mutation stub for security tests
|
|
332
352
|
if (method === "POST" && pathname === "/api/session/ping") {
|
|
333
353
|
await handleSessionPing(req, res, ctx);
|