@tea-agent/loop-agent 0.20.1-beta.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/application/dag/args.js +29 -0
  3. package/dist/application/dag/run-dag.js +3 -1
  4. package/dist/cli/command-definitions.js +15 -1
  5. package/dist/cli/program.js +11 -1
  6. package/dist/commands/dag-rerun-task.js +19 -0
  7. package/dist/commands/dag-rerun.js +111 -0
  8. package/dist/commands/init.js +7 -0
  9. package/dist/executors/dag-pi-executor.js +24 -0
  10. package/dist/executors/pi-executor.js +111 -36
  11. package/dist/executors/pi-sdk-executor.js +105 -29
  12. package/dist/executors/shell-executor.js +54 -11
  13. package/dist/shared/operator/capabilities.js +54 -0
  14. package/dist/worker/console/index.js +1 -1
  15. package/dist/worker/console/inspect-split.js +82 -0
  16. package/dist/worker/console/operation-runner.js +3 -1
  17. package/dist/worker/console/operation-store.js +1 -0
  18. package/dist/worker/console/operator-actions.js +153 -2
  19. package/dist/worker/console/operator-user-error.js +10 -0
  20. package/dist/worker/console/pi-readiness.js +4 -0
  21. package/dist/worker/console/recovery-cta.js +116 -5
  22. package/dist/worker/console/recovery-selection.js +107 -0
  23. package/dist/worker/console/resolve-dag-run-for-task.js +50 -11
  24. package/dist/worker/console/routes.js +20 -0
  25. package/dist/worker/console/sibling-controller.js +12 -7
  26. package/dist/worker/console/static/assets/index-CUDke82y.js +18 -0
  27. package/dist/worker/console/static/assets/index-wSEksVSO.css +1 -0
  28. package/dist/worker/console/static/index.html +2 -2
  29. package/dist/worker/loop-agent/loop-agent-client.js +43 -9
  30. package/dist/worker/observability/read-model.js +67 -1
  31. package/dist/worker/observe/static/constants.js +5 -0
  32. package/dist/worker/observe/static/format-pool.js +22 -3
  33. package/dist/worker/observe/static/index.html +1 -1
  34. package/dist/worker/observe/static/styles.css +32 -3
  35. package/dist/worker/observe/static/views/dag-inspector.js +2 -2
  36. package/dist/worker/run-task/run-task.js +23 -6
  37. package/dist/workflows/dag/backend-test-markdown-workflow.js +291 -97
  38. package/dist/workflows/dag/backend-test-result-contract.js +10 -4
  39. package/dist/workflows/dag/frontend-test-result-contract.js +64 -0
  40. package/dist/workflows/dag/init-hybrid.js +117 -64
  41. package/dist/workflows/dag/lifecycle.js +60 -4
  42. package/dist/workflows/dag/liveness-policy.js +250 -0
  43. package/dist/workflows/dag/node-execution.js +89 -6
  44. package/dist/workflows/dag/output-protocol.js +76 -0
  45. package/dist/workflows/dag/rerun-plan.js +611 -0
  46. package/dist/workflows/dag/rerun-run.js +497 -0
  47. package/dist/workflows/dag/rerun-task.js +284 -0
  48. package/dist/workflows/dag/retry-policy.js +20 -1
  49. package/dist/workflows/dag/runner.js +71 -1
  50. package/dist/workflows/dag/skill-snapshot.js +22 -3
  51. package/dist/workflows/dag/types.js +12 -0
  52. package/dist/workflows/dag/validate.js +11 -0
  53. package/dist/workflows/dag/workspace-checkpoint.js +163 -0
  54. package/docs/README.md +5 -5
  55. package/docs/architecture/dag-execution.md +11 -0
  56. package/docs/architecture/facts-and-state.md +1 -0
  57. package/docs/architecture/worker-and-feature.md +10 -0
  58. package/docs/templates/agent-dag.schema.json +17 -2
  59. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
  60. package/docs/templates/backend-test-dag.json +15 -15
  61. package/docs/templates/frontend-test-case-checklist.md +16 -1
  62. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +26 -2
  63. package/docs/templates/frontend-test-dag.json +65 -6
  64. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +4 -1
  65. package/harness.json +1 -1
  66. package/package.json +1 -1
  67. package/skills/loop-agent/references/command-reference.md +5 -0
  68. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  69. package/skills/playwright-cli-case-generator/SKILL.md +35 -7
  70. package/dist/worker/console/static/assets/index-3vsjZJHq.js +0 -16
  71. 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 env = result.json;
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: "status",
10
- commandHint: "loop-agent dag report --run-id <run-id>",
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": ["report", "doctor", "resume", "regenerate"],
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": ["report", "doctor", "resume", "regenerate"],
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 the newest DAG run id for a harness task under `.harness/dag-runs`.
3
- * Used by Console observeLink so task-scoped opens land on `#/dag/<runId>`.
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
- * Prefer active/paused over completed; within a lifecycle, newest mtime wins.
28
- */
29
- export async function resolveLatestDagRunIdForTask(repoRoot, taskId) {
30
- const wanted = taskId.trim();
31
- if (!wanted)
32
- return undefined;
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
- return matches[0];
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);