@tea-agent/loop-agent 0.9.0 → 0.10.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 (63) hide show
  1. package/CHANGELOG.md +42 -11
  2. package/README.md +20 -0
  3. package/dist/cli/command-definitions.js +7 -0
  4. package/dist/cli/program.js +6 -1
  5. package/dist/commands/dag-reconcile-run.js +118 -0
  6. package/dist/commands/init.js +12 -3
  7. package/dist/governance/manifest-types.js +4 -0
  8. package/dist/worker/cli.js +216 -0
  9. package/dist/worker/closeout/apply.js +73 -0
  10. package/dist/worker/closeout/preview.js +30 -0
  11. package/dist/worker/delivery/final-verification.js +158 -0
  12. package/dist/worker/delivery/git-transaction.js +354 -0
  13. package/dist/worker/delivery/package.js +449 -0
  14. package/dist/worker/feature/decision-loader.js +68 -0
  15. package/dist/worker/feature/discover.js +14 -0
  16. package/dist/worker/feature/next-action.js +74 -0
  17. package/dist/worker/feature/reducer.js +133 -0
  18. package/dist/worker/feature/review.js +502 -0
  19. package/dist/worker/feature/run.js +313 -0
  20. package/dist/worker/feature/types.js +1 -0
  21. package/dist/worker/follow-up/approve.js +270 -0
  22. package/dist/worker/follow-up/factory.js +234 -0
  23. package/dist/worker/follow-up/paths.js +25 -0
  24. package/dist/worker/follow-up/policy.js +26 -0
  25. package/dist/worker/follow-up/schema.js +93 -0
  26. package/dist/worker/follow-up/store.js +96 -0
  27. package/dist/worker/metrics/projector.js +139 -0
  28. package/dist/worker/observability/read-model.js +256 -15
  29. package/dist/worker/observe/paths.js +17 -5
  30. package/dist/worker/observe/static/app.js +443 -61
  31. package/dist/worker/observe/static/index.html +3 -1
  32. package/dist/worker/observe/static/styles.css +86 -19
  33. package/dist/worker/pool/run-store.js +14 -2
  34. package/dist/worker/pool/validation.js +59 -0
  35. package/dist/worker/report/morning-report.js +41 -6
  36. package/dist/worker/run-task/run-task.js +1 -1
  37. package/dist/worker/runner/run-ready.js +19 -5
  38. package/dist/workflows/dag/init-hybrid.js +3 -1
  39. package/dist/workflows/dag/lifecycle.js +146 -0
  40. package/dist/workflows/dag/node-execution.js +3 -0
  41. package/dist/workflows/dag/prompt.js +16 -0
  42. package/dist/workflows/dag/report.js +2 -0
  43. package/dist/workflows/dag/runner.js +133 -104
  44. package/dist/workflows/dag/types.js +3 -0
  45. package/docs/README.md +17 -0
  46. package/docs/agent-dag-recovery-playbook.md +1 -1
  47. package/docs/architecture/runtime-boundaries.md +3 -2
  48. package/docs/design/README.md +11 -5
  49. package/docs/exec-plans/active/README.md +1 -1
  50. package/docs/exec-plans/completed/README.md +8 -1
  51. package/docs/loop-agent-harness.md +45 -2
  52. package/docs/progress/README.md +2 -0
  53. package/docs/reports/README.md +10 -0
  54. package/docs/templates/agent-dag-report.schema.json +5 -3
  55. package/docs/templates/harness.schema.json +7 -2
  56. package/docs/templates/init-evolution-review.md +4 -2
  57. package/docs/verification-matrix.md +7 -0
  58. package/harness.json +4 -3
  59. package/package.json +4 -2
  60. package/scripts/check-product-line-docs.sh +7 -3
  61. package/skills/init-capability-evolution/SKILL.md +1 -0
  62. package/skills/loop-agent/references/command-reference.md +21 -0
  63. package/skills/loop-agent/references/hybrid-dag.md +4 -3
@@ -3,6 +3,12 @@ import { layoutDag } from "./dag-layout.js";
3
3
  const ACTIVE_DETAIL_POLL_MS = 2000;
4
4
  const HIDDEN_POLL_MS = 30000;
5
5
  const DASHBOARD_POLL_MS = 5000;
6
+ const DAG_INSPECTOR_DEFAULT_WIDTH = 440;
7
+ const DAG_INSPECTOR_MIN_WIDTH = 360;
8
+ const DAG_INSPECTOR_MAX_WIDTH = 680;
9
+ const DAG_INSPECTOR_RIGHT_OFFSET = 24;
10
+ const DAG_INSPECTOR_VIEWPORT_MARGIN = 48;
11
+ const DAG_INSPECTOR_KEYBOARD_STEP = 24;
6
12
 
7
13
  const UI_TEXT = {
8
14
  dashboard: "总览",
@@ -56,10 +62,28 @@ const STATUS_LABELS = {
56
62
  };
57
63
 
58
64
  const LIVENESS_LABELS = {
59
- active: "活跃",
65
+ active: "执行器正常",
66
+ "node-quiet": "执行器正常,节点长时间无活动",
60
67
  quiet: "无输出(存活)",
61
- stale: "心跳失联",
68
+ stale: "执行器心跳中断",
62
69
  "timeout-risk": "即将超时",
70
+ orphaned: "执行器已退出",
71
+ "unknown-host": "无法判断(在其他主机运行)",
72
+ unknown: "无法判断(无心跳记录)",
73
+ };
74
+
75
+ const DAG_EFFECTIVE_STATUS_LABELS = {
76
+ pending: "等待执行",
77
+ running: "正在执行",
78
+ "running-quiet": "运行可疑",
79
+ paused: "已暂停",
80
+ interrupted: "执行已中断",
81
+ "remote-unknown": "远端状态未知",
82
+ finished: "已完成",
83
+ failed: "执行失败",
84
+ superseded: "任务已另行完成",
85
+ abandoned: "已放弃",
86
+ unknown: "状态未知",
63
87
  };
64
88
 
65
89
  const STEP_LABELS = {
@@ -135,6 +159,7 @@ let sessionEvents = [];
135
159
  let lastSnapshot = null;
136
160
  let dagInspectorOpen = false;
137
161
  let dagInspectorTab = "output";
162
+ let dagInspectorWidth = null;
138
163
  let dagGraphViewportState = null;
139
164
  const dagGraphViewportStates = new Map();
140
165
  let dagTimelineViewportState = null;
@@ -372,16 +397,18 @@ function statusSymbol(status) {
372
397
  return "◇";
373
398
  }
374
399
 
375
- function badgeClass(status) {
400
+ export function badgeClass(status) {
376
401
  if (!status) return "unknown";
377
402
  const key = status.toLowerCase().replace(/_/g, "-");
378
- if (key === "finished" || key === "completed" || key === "succeeded" || key === "done") {
403
+ if (["finished", "completed", "succeeded", "done", "superseded"].includes(key)) {
379
404
  return "succeeded";
380
405
  }
381
- if (key === "error" || key === "failed") return "failed";
406
+ if (["error", "failed", "interrupted"].includes(key)) return "failed";
382
407
  if (key === "partial-failed") return "partial-failed";
383
- if (key === "skipped") return "skipped";
384
- if (key === "running" || key === "started") return "running";
408
+ if (key === "skipped" || key === "abandoned") return "skipped";
409
+ if (key === "running" || key === "started" || key === "running-quiet") return "running";
410
+ if (key === "paused") return "blocked";
411
+ if (key === "remote-unknown") return "unknown";
385
412
  return key;
386
413
  }
387
414
 
@@ -398,12 +425,25 @@ function formatTs(value) {
398
425
  return d.toLocaleString("zh-CN", { hour12: false });
399
426
  }
400
427
 
401
- function formatMs(ms) {
428
+ export function formatMs(ms) {
402
429
  if (ms === undefined || ms === null) return "—";
403
430
  if (ms < 1000) return `${ms}ms`;
431
+ if (ms >= 60_000) {
432
+ const totalSeconds = Math.floor(ms / 1000);
433
+ return `${Math.floor(totalSeconds / 60)}min ${totalSeconds % 60}s`;
434
+ }
404
435
  return `${(ms / 1000).toFixed(1)}s`;
405
436
  }
406
437
 
438
+ function dagStatusBadge(status) {
439
+ const value = status ?? "unknown";
440
+ return el(
441
+ "span",
442
+ `badge badge-${badgeClass(value)}`,
443
+ DAG_EFFECTIVE_STATUS_LABELS[value] ?? statusLabel(value),
444
+ );
445
+ }
446
+
407
447
  function formatDuration(ms) {
408
448
  if (ms === undefined || ms === null || Number.isNaN(ms)) return "—";
409
449
  if (ms < 0) ms = 0;
@@ -518,6 +558,25 @@ function metaGrid(entries) {
518
558
  return dl;
519
559
  }
520
560
 
561
+ const TERMINAL_DAG_EFFECTIVE_STATUSES = new Set([
562
+ "finished",
563
+ "failed",
564
+ "superseded",
565
+ "abandoned",
566
+ ]);
567
+
568
+ export function dagMetaVisibility(dag) {
569
+ const terminal =
570
+ (dag?.lifecycle ?? "").toLowerCase() === "completed"
571
+ || TERMINAL_DAG_EFFECTIVE_STATUSES.has(dag?.effectiveStatus);
572
+ return {
573
+ showExecutorStatus: !terminal,
574
+ showConsistencyWarning: dag?.stateConsistent === false,
575
+ showResume: dag?.recoveryEligibility?.canResume === true,
576
+ showReconcile: dag?.recoveryEligibility?.canReconcile === true,
577
+ };
578
+ }
579
+
521
580
  function buildTable(headers, rows) {
522
581
  const table = document.createElement("table");
523
582
  const thead = document.createElement("thead");
@@ -635,6 +694,10 @@ function formatSessionEventLabel(event) {
635
694
  if (!event || typeof event !== "object") return "未知类型";
636
695
  const type = typeof event.type === "string" ? event.type : "unknown";
637
696
  const toolName = event.toolName ?? event.name ?? "—";
697
+ if (type === "agent_start") return "Agent 已启动";
698
+ if (type === "agent_end") return "Agent 已结束";
699
+ if (type === "agent_settled") return "Agent 已稳定";
700
+ if (type === "turn_start") return "开始新一轮处理";
638
701
  if (type === "tool_execution_start" || type === "tool_start") {
639
702
  return `调用工具 · ${toolName}`;
640
703
  }
@@ -647,16 +710,53 @@ function formatSessionEventLabel(event) {
647
710
  return type;
648
711
  }
649
712
 
650
- function formatSessionEventTime(event) {
651
- if (!event || typeof event !== "object") return "";
713
+ function sessionEventTimestamp(event) {
714
+ if (!event || typeof event !== "object") return "";
652
715
  const ts =
653
716
  (typeof event.timestamp === "string" && event.timestamp) ||
654
717
  (typeof event.at === "string" && event.at) ||
655
718
  (typeof event.recordedAt === "string" && event.recordedAt);
656
- if (!ts) return "";
719
+ return ts || "";
720
+ }
721
+
722
+ function formatSessionEventTime(event) {
723
+ const ts = sessionEventTimestamp(event);
724
+ if (!ts) return "未记录时间";
657
725
  return formatTs(ts).replace(/\s.*/, "") || formatTs(ts);
658
726
  }
659
727
 
728
+ function sessionEventKind(event) {
729
+ const type = typeof event?.type === "string" ? event.type : "unknown";
730
+ if (type === "tool_execution_start" || type === "tool_start") return "tool-start";
731
+ if (type === "tool_execution_end" || type === "tool_end") return "tool-end";
732
+ if (type === "turn_end" || type === "assistant" || type === "assistant_message") {
733
+ return "assistant";
734
+ }
735
+ if (type.startsWith("agent_") || type === "turn_start") return "lifecycle";
736
+ return "protocol";
737
+ }
738
+
739
+ function buildSessionTimelineItems(events) {
740
+ const items = [];
741
+ for (let index = 0; index < events.length; index += 1) {
742
+ const event = events[index];
743
+ const type = typeof event?.type === "string" ? event.type : "unknown";
744
+ if (type === "message_start" || type === "message_end") {
745
+ let count = 1;
746
+ while (index + 1 < events.length) {
747
+ const nextType = events[index + 1]?.type;
748
+ if (nextType !== "message_start" && nextType !== "message_end") break;
749
+ count += 1;
750
+ index += 1;
751
+ }
752
+ items.push({ event, kind: "protocol", protocolCount: count });
753
+ continue;
754
+ }
755
+ items.push({ event, kind: sessionEventKind(event) });
756
+ }
757
+ return items;
758
+ }
759
+
660
760
  async function mergeSessionEvents(dagRunId, nodeId) {
661
761
  if (!dagRunId || !nodeId) return;
662
762
  const page = await fetchJson(
@@ -691,6 +791,94 @@ function closeDagInspector() {
691
791
  }
692
792
  }
693
793
 
794
+ function closeDagInspectorOnOutsideClick(event) {
795
+ if (!dagInspectorOpen || !(event.target instanceof Element)) return;
796
+ if (
797
+ event.target.closest(
798
+ "#dag-inspector, .dag-node-table tbody tr, button, a, input, select, textarea, [role='button'], [role='tab']",
799
+ )
800
+ ) {
801
+ return;
802
+ }
803
+ closeDagInspector();
804
+ }
805
+
806
+ function dagInspectorMaximumWidth() {
807
+ return Math.max(
808
+ DAG_INSPECTOR_MIN_WIDTH,
809
+ Math.min(
810
+ DAG_INSPECTOR_MAX_WIDTH,
811
+ window.innerWidth - DAG_INSPECTOR_VIEWPORT_MARGIN,
812
+ ),
813
+ );
814
+ }
815
+
816
+ function setDagInspectorWidth(panel, requestedWidth) {
817
+ const maximum = dagInspectorMaximumWidth();
818
+ dagInspectorWidth = Math.min(
819
+ maximum,
820
+ Math.max(DAG_INSPECTOR_MIN_WIDTH, Math.round(requestedWidth)),
821
+ );
822
+ panel.style.setProperty("--dag-inspector-width", `${dagInspectorWidth}px`);
823
+ return { minimum: DAG_INSPECTOR_MIN_WIDTH, maximum, value: dagInspectorWidth };
824
+ }
825
+
826
+ function createDagInspectorResizeHandle(panel) {
827
+ const handle = el("div", "dag-inspector-resize-handle");
828
+ handle.tabIndex = 0;
829
+ handle.setAttribute("role", "separator");
830
+ handle.setAttribute("aria-orientation", "vertical");
831
+ handle.setAttribute("aria-label", "调整节点检查器宽度");
832
+ handle.title = "拖动调整节点检查器宽度";
833
+
834
+ const updateRange = (requestedWidth) => {
835
+ const range = setDagInspectorWidth(
836
+ panel,
837
+ requestedWidth ?? dagInspectorWidth ?? DAG_INSPECTOR_DEFAULT_WIDTH,
838
+ );
839
+ handle.setAttribute("aria-valuemin", String(range.minimum));
840
+ handle.setAttribute("aria-valuemax", String(range.maximum));
841
+ handle.setAttribute("aria-valuenow", String(range.value));
842
+ };
843
+
844
+ updateRange();
845
+ handle.addEventListener("pointerdown", (event) => {
846
+ if (event.button !== 0) return;
847
+ event.preventDefault();
848
+ handle.setPointerCapture(event.pointerId);
849
+ document.body.classList.add("is-resizing-dag-inspector");
850
+ });
851
+ handle.addEventListener("pointermove", (event) => {
852
+ if (!handle.hasPointerCapture(event.pointerId)) return;
853
+ updateRange(window.innerWidth - event.clientX - DAG_INSPECTOR_RIGHT_OFFSET);
854
+ });
855
+ const stopResizing = (event) => {
856
+ if (handle.hasPointerCapture(event.pointerId)) {
857
+ handle.releasePointerCapture(event.pointerId);
858
+ }
859
+ document.body.classList.remove("is-resizing-dag-inspector");
860
+ };
861
+ handle.addEventListener("pointerup", stopResizing);
862
+ handle.addEventListener("pointercancel", stopResizing);
863
+ handle.addEventListener("keydown", (event) => {
864
+ const currentWidth = dagInspectorWidth ?? DAG_INSPECTOR_DEFAULT_WIDTH;
865
+ if (event.key === "ArrowLeft") {
866
+ event.preventDefault();
867
+ updateRange(currentWidth + DAG_INSPECTOR_KEYBOARD_STEP);
868
+ } else if (event.key === "ArrowRight") {
869
+ event.preventDefault();
870
+ updateRange(currentWidth - DAG_INSPECTOR_KEYBOARD_STEP);
871
+ } else if (event.key === "Home") {
872
+ event.preventDefault();
873
+ updateRange(DAG_INSPECTOR_MIN_WIDTH);
874
+ } else if (event.key === "End") {
875
+ event.preventDefault();
876
+ updateRange(dagInspectorMaximumWidth());
877
+ }
878
+ });
879
+ return handle;
880
+ }
881
+
694
882
  function captureDagTimelineViewportState(panel) {
695
883
  const scrollEl = panel.querySelector(".process-timeline-scroll");
696
884
  if (scrollEl?.dataset.dagRunId && scrollEl.dataset.nodeId) {
@@ -716,11 +904,33 @@ function renderSessionTimeline(content, nodeId) {
716
904
  return;
717
905
  }
718
906
 
907
+ const timelineItems = buildSessionTimelineItems(sessionEvents);
908
+ const hasEventTimes = sessionEvents.some((event) => Boolean(sessionEventTimestamp(event)));
909
+ if (!hasEventTimes) {
910
+ content.appendChild(
911
+ el("p", "process-timeline-note", "事件未记录具体时间,以下按采集顺序展示。"),
912
+ );
913
+ }
719
914
  const ul = el("ul", "process-timeline");
720
- for (const event of sessionEvents) {
721
- const li = el("li", "process-timeline-item");
722
- li.appendChild(el("span", "process-timeline-time", formatSessionEventTime(event)));
723
- li.appendChild(el("span", "process-timeline-label", formatSessionEventLabel(event)));
915
+ for (let index = 0; index < timelineItems.length; index += 1) {
916
+ const item = timelineItems[index];
917
+ const li = el(
918
+ "li",
919
+ `process-timeline-item process-timeline-${item.kind}${hasEventTimes ? " has-time" : ""}`,
920
+ );
921
+ if (index === 0) li.classList.add("is-first");
922
+ if (index === timelineItems.length - 1) li.classList.add("is-last");
923
+ const rail = el("span", "process-timeline-rail");
924
+ rail.appendChild(el("span", "process-timeline-dot"));
925
+ li.appendChild(rail);
926
+ if (hasEventTimes) {
927
+ li.appendChild(el("span", "process-timeline-time", formatSessionEventTime(item.event)));
928
+ }
929
+ const label =
930
+ item.protocolCount > 1
931
+ ? `消息收发 · 已合并 ${item.protocolCount} 条协议事件`
932
+ : formatSessionEventLabel(item.event);
933
+ li.appendChild(el("span", "process-timeline-label", label));
724
934
  ul.appendChild(li);
725
935
  }
726
936
  content.appendChild(ul);
@@ -756,12 +966,27 @@ function renderSessionTimeline(content, nodeId) {
756
966
  }
757
967
 
758
968
  export function isDagRunActive(dag) {
969
+ if (["running", "running-quiet", "remote-unknown", "pending"].includes(dag.effectiveStatus)) return true;
970
+ // Only "unknown" (and absent) means liveness evidence is insufficient;
971
+ // it must NOT override raw lifecycle/status/node facts. Every other concrete
972
+ // effectiveStatus is authoritative-non-active and short-circuits to false.
973
+ if (dag.effectiveStatus && dag.effectiveStatus !== "unknown") return false;
759
974
  const status = (dag.status ?? "").toLowerCase();
975
+ if ((dag.lifecycle ?? "").toLowerCase() === "paused") return false;
976
+ if (["orphaned", "stale"].includes((dag.liveness ?? "").toLowerCase())) return false;
760
977
  if (TERMINAL_RUN_STATUSES.has(status)) return false;
761
978
  if (["running", "pending", "started"].includes(status)) return true;
762
979
  return (dag.nodes ?? []).some((n) => isNodeActive(n.status));
763
980
  }
764
981
 
982
+ const DASHBOARD_FEATURE_STATUSES = new Set(["running", "needs_action"]);
983
+
984
+ export function dashboardVisibleFeatures(features) {
985
+ return (features ?? []).filter((feature) =>
986
+ DASHBOARD_FEATURE_STATUSES.has(feature?.status),
987
+ );
988
+ }
989
+
765
990
  function dagProgress(dag) {
766
991
  const nodes = dag.nodes ?? [];
767
992
  const finished = nodes.filter((n) => isNodeFinished(n.status)).length;
@@ -786,13 +1011,69 @@ function findDagForRun(snapshot, workerRunId, task) {
786
1011
  return undefined;
787
1012
  }
788
1013
 
789
- function kpiCard(label, value, extraClass) {
1014
+ let kpiHelpSeed = 0;
1015
+ function kpiCard(label, value, extraClass, helpText) {
790
1016
  const card = el("div", `kpi-card${extraClass ? ` ${extraClass}` : ""}`);
791
1017
  card.appendChild(el("div", "kpi-label", label));
792
- card.appendChild(el("div", "kpi-value", String(value)));
1018
+ const valueWrap = el("div", "kpi-value-row");
1019
+ valueWrap.appendChild(el("div", "kpi-value", String(value)));
1020
+ if (helpText) {
1021
+ kpiHelpSeed += 1;
1022
+ const tipId = `kpi-tip-${kpiHelpSeed}`;
1023
+ const helpBtn = el("button", "kpi-help");
1024
+ helpBtn.type = "button";
1025
+ helpBtn.setAttribute("aria-describedby", tipId);
1026
+ helpBtn.setAttribute("aria-label", `${label} 指标说明`);
1027
+ const icon = el("i", "ri-question-line");
1028
+ icon.setAttribute("aria-hidden", "true");
1029
+ helpBtn.appendChild(icon);
1030
+ const tip = el("span", "kpi-tip");
1031
+ tip.id = tipId;
1032
+ tip.setAttribute("role", "tooltip");
1033
+ tip.textContent = helpText;
1034
+ valueWrap.appendChild(helpBtn);
1035
+ valueWrap.appendChild(tip);
1036
+ }
1037
+ card.appendChild(valueWrap);
793
1038
  return card;
794
1039
  }
795
1040
 
1041
+ function repoBaseName(repoRoot) {
1042
+ if (!repoRoot) return "未绑定仓库";
1043
+ const trimmed = String(repoRoot).replace(/[\\/]+$/, "");
1044
+ const parts = trimmed.split(/[\\/]/);
1045
+ return parts[parts.length - 1] || trimmed || "未绑定仓库";
1046
+ }
1047
+
1048
+ function renderRepoBanner(repoEl, snapshot) {
1049
+ if (!repoEl) return;
1050
+ clearNode(repoEl);
1051
+ const repoRoot = snapshot.repoRoot ?? "";
1052
+ const icon = el("i", "ri-git-repository-line");
1053
+ icon.setAttribute("aria-hidden", "true");
1054
+ repoEl.appendChild(icon);
1055
+ const text = el("div", "repo-banner-text");
1056
+ text.appendChild(el("span", "repo-banner-name", repoBaseName(repoRoot)));
1057
+ const pathLine = el("span", "repo-banner-path", repoRoot || "路径未知");
1058
+ pathLine.title = repoRoot || "";
1059
+ text.appendChild(pathLine);
1060
+ repoEl.appendChild(text);
1061
+ }
1062
+
1063
+ function renderProjectionFault(error) {
1064
+ const banner = el("div", "projection-fault");
1065
+ banner.setAttribute("role", "alert");
1066
+ const icon = el("i", "ri-error-warning-line");
1067
+ icon.setAttribute("aria-hidden", "true");
1068
+ banner.appendChild(icon);
1069
+ const body = el("div", "projection-fault-body");
1070
+ body.appendChild(el("p", "projection-fault-title", "指标暂不可用:数据投影失败。"));
1071
+ body.appendChild(el("p", "projection-fault-message", error.message ?? "投影失败 (projection failed)"));
1072
+ if (error.at) body.appendChild(el("p", "projection-fault-at", `发生时间:${error.at}`));
1073
+ banner.appendChild(body);
1074
+ return banner;
1075
+ }
1076
+
796
1077
  function renderActiveDagCard(dag, nowMs) {
797
1078
  const card = el("div", "active-dag-card");
798
1079
  if (isNodeActive(dag.status) || getActiveNodes(dag).length > 0) {
@@ -801,7 +1082,7 @@ function renderActiveDagCard(dag, nowMs) {
801
1082
  const title = dag.title || dag.dagRunId;
802
1083
  const header = el("div", "active-dag-header");
803
1084
  header.appendChild(el("span", "active-dag-title", title));
804
- header.appendChild(badge(dag.status));
1085
+ header.appendChild(dagStatusBadge(dag.effectiveStatus ?? dag.status));
805
1086
  card.appendChild(header);
806
1087
 
807
1088
  const progress = dagProgress(dag);
@@ -1121,6 +1402,7 @@ function renderDagInspector(dagRunId, node) {
1121
1402
  clearNode(panel);
1122
1403
  panel.classList.add("is-open");
1123
1404
  panel.setAttribute("aria-hidden", "false");
1405
+ panel.appendChild(createDagInspectorResizeHandle(panel));
1124
1406
  const header = el("div", "dag-inspector-header");
1125
1407
  const title = el("div", "dag-inspector-title");
1126
1408
  title.appendChild(el("h3", null, "节点检查器"));
@@ -1242,10 +1524,12 @@ async function renderDashboard(scrollTo) {
1242
1524
  setBreadcrumb([{ label: UI_TEXT.dashboard }]);
1243
1525
 
1244
1526
  const kpiEl = document.getElementById("dashboard-kpi");
1527
+ const featureEl = document.getElementById("dashboard-features");
1245
1528
  const activeEl = document.getElementById("dashboard-active-dags");
1246
1529
  const riskEl = document.getElementById("dashboard-risk");
1247
1530
  const dagsEl = document.getElementById("dashboard-dags");
1248
1531
  const batchesEl = document.getElementById("dashboard-batches");
1532
+ const repoEl = document.getElementById("dashboard-repo");
1249
1533
 
1250
1534
  const snapshot = await fetchJson("/api/snapshot");
1251
1535
  if (!snapshot) return;
@@ -1260,21 +1544,88 @@ async function renderDashboard(scrollTo) {
1260
1544
  .sort((a, b) => dagSortTime(b).localeCompare(dagSortTime(a)))
1261
1545
  .slice(0, 10);
1262
1546
 
1547
+ const features = dashboardVisibleFeatures(snapshot.features);
1548
+ featureEl.hidden = features.length === 0;
1549
+ clearNode(featureEl);
1550
+ if (features.length > 0) {
1551
+ featureEl.appendChild(el("h3", "section-heading", "Feature 决策"));
1552
+ featureEl.appendChild(buildTable(
1553
+ ["Feature", "状态", "下一步", "原因", "required AC", "证据"],
1554
+ features.map((feature) => ({ cells: [
1555
+ feature.featureId,
1556
+ feature.statusLabel ?? feature.status,
1557
+ feature.nextAction?.command ?? feature.nextAction?.label ?? "—",
1558
+ feature.blockingItems?.[0]?.message ?? `${feature.summary?.tasksSucceeded ?? 0}/${feature.summary?.tasksTotal ?? 0} tasks completed`,
1559
+ `${feature.summary?.requiredAcCovered ?? 0}/${feature.summary?.requiredAcTotal ?? 0}`,
1560
+ artifactLink("查看", feature.evidence?.delivery ?? feature.evidence?.closeout ?? feature.evidence?.morningReport ?? feature.evidence?.observeSnapshot),
1561
+ ] })),
1562
+ ));
1563
+ }
1564
+ if (features.length > 0 && (snapshot.projectionWarnings ?? []).length > 0) {
1565
+ featureEl.appendChild(el("p", "empty", `投影警告:${snapshot.projectionWarnings.join(";")}`));
1566
+ }
1567
+
1568
+ renderRepoBanner(repoEl, snapshot);
1569
+
1263
1570
  clearNode(kpiEl);
1571
+ const dagHealth = snapshot.health?.dag;
1572
+ const dagActiveRuns = dagHealth?.activeRuns ?? activeDags.length;
1573
+ const dagRunningNodes = dagHealth?.runningNodes
1574
+ ?? activeDags.flatMap((dag) => dag.nodes ?? []).filter((node) => ["running", "started"].includes((node.status ?? "").toLowerCase())).length;
1575
+ const dagPendingNodes = dagHealth?.pendingNodes
1576
+ ?? activeDags.flatMap((dag) => dag.nodes ?? []).filter((node) => ["pending", "queued"].includes((node.status ?? "").toLowerCase())).length;
1577
+ const dagPausedRuns = dagHealth?.pausedRuns ?? 0;
1578
+ const dagStaleRuns = dagHealth?.staleRuns ?? 0;
1579
+ const dagInterruptedRuns = dagHealth?.interruptedRuns ?? 0;
1580
+ const dagInconsistentRuns = dagHealth?.inconsistentRuns ?? 0;
1581
+ const dagAttentionRuns = dagHealth?.attentionRuns
1582
+ ?? dagRuns.filter((dag) => (dag.lifecycle ?? "").toLowerCase() !== "completed" && (
1583
+ (dag.lifecycle ?? "").toLowerCase() === "paused"
1584
+ || ["stale", "orphaned"].includes((dag.liveness ?? "").toLowerCase())
1585
+ || ["interrupted", "remote-unknown"].includes(dag.effectiveStatus)
1586
+ || dag.stateConsistent === false
1587
+ )).length;
1588
+
1264
1589
  const quietCount = snapshot.health?.quietCount ?? 0;
1265
1590
  const staleCount = snapshot.health?.staleCount ?? 0;
1266
1591
  const timeoutRiskCount = snapshot.health?.timeoutRiskCount ?? 0;
1267
1592
  const failuresCount = snapshot.health?.failuresCount ?? 0;
1268
- kpiEl.appendChild(kpiCard("活跃 DAG", activeDags.length, "kpi-highlight"));
1269
- kpiEl.appendChild(kpiCard("活跃 Task", snapshot.health?.activeTasks ?? 0));
1270
- kpiEl.appendChild(kpiCard("无输出", quietCount, quietCount > 0 ? "kpi-muted" : ""));
1271
- kpiEl.appendChild(kpiCard("心跳失联", staleCount, staleCount > 0 ? "kpi-risk" : ""));
1272
- kpiEl.appendChild(
1273
- kpiCard("即将超时", timeoutRiskCount, timeoutRiskCount > 0 ? "kpi-risk" : ""),
1274
- );
1275
- kpiEl.appendChild(
1276
- kpiCard("失败", failuresCount, failuresCount > 0 ? "kpi-danger" : ""),
1277
- );
1593
+
1594
+ if (snapshot.projectionError) {
1595
+ kpiEl.appendChild(renderProjectionFault(snapshot.projectionError));
1596
+ } else {
1597
+ const dagGroup = el("fieldset", "kpi-group");
1598
+ dagGroup.appendChild(el("legend", "kpi-group-legend", "DAG 运行"));
1599
+ const dagCards = el("div", "kpi-grid");
1600
+ dagCards.appendChild(kpiCard("活跃 DAG", dagActiveRuns, "kpi-highlight",
1601
+ "当前处于运行、静默运行、远端状态未知或等待执行的 DAG 数量(来源:.harness/dag-runs 的有效状态)。"));
1602
+ dagCards.appendChild(kpiCard("执行中节点", dagRunningNodes, "",
1603
+ "活跃 DAG 中状态为 running 或 started、正在执行的节点数量;DAG 内执行单元统一称为节点。"));
1604
+ dagCards.appendChild(kpiCard("等待节点", dagPendingNodes, "",
1605
+ "活跃 DAG 中状态为 pending 或 queued、尚未开始执行的节点数量。"));
1606
+ dagCards.appendChild(kpiCard("暂停 DAG", dagPausedRuns, dagPausedRuns > 0 ? "kpi-muted" : "",
1607
+ "生命周期被暂停的 DAG 运行数量(来源:.harness/dag-runs 下的 lifecycle 目录)。"));
1608
+ dagCards.appendChild(kpiCard("需处理 DAG", dagAttentionRuns, dagAttentionRuns > 0 ? "kpi-risk" : "",
1609
+ "未完成且暂停、失联、中断、远端状态未知或状态不一致的 DAG 数量;同一个 DAG 只计一次。"));
1610
+ dagGroup.appendChild(dagCards);
1611
+
1612
+ const workerGroup = el("fieldset", "kpi-group");
1613
+ workerGroup.appendChild(el("legend", "kpi-group-legend", "Worker 执行"));
1614
+ const workerCards = el("div", "kpi-grid");
1615
+ workerCards.appendChild(kpiCard("活跃 Worker Task", snapshot.health?.activeTasks ?? 0, "",
1616
+ "当前运行或等待执行的 Worker Task Pool 任务数量(来源:.harness/task-pool 的状态、运行记录与事件;不含 .harness/tasks 下的 task.json)。"));
1617
+ workerCards.appendChild(kpiCard("无输出", quietCount, quietCount > 0 ? "kpi-muted" : "",
1618
+ "心跳仍正常,但超过无输出判定时长仍没有新输出的 Worker Task 数量。"));
1619
+ workerCards.appendChild(kpiCard("心跳失联", staleCount, staleCount > 0 ? "kpi-risk" : "",
1620
+ "超过心跳失联判定时长仍没有新心跳的 Worker Task 数量。"));
1621
+ workerCards.appendChild(kpiCard("即将超时", timeoutRiskCount, timeoutRiskCount > 0 ? "kpi-risk" : "",
1622
+ "命令已执行时长超过 80% 超时的 Worker Task 数量。"));
1623
+ workerCards.appendChild(kpiCard("失败", failuresCount, failuresCount > 0 ? "kpi-danger" : "",
1624
+ "状态为 failed 的 Worker Task 数量。"));
1625
+ workerGroup.appendChild(workerCards);
1626
+ kpiEl.appendChild(workerGroup);
1627
+ kpiEl.appendChild(dagGroup);
1628
+ }
1278
1629
 
1279
1630
  const nowMs = Date.now();
1280
1631
 
@@ -1292,28 +1643,39 @@ async function renderDashboard(scrollTo) {
1292
1643
  activeEl.appendChild(list);
1293
1644
  }
1294
1645
 
1295
- const riskCount = failuresCount + staleCount + timeoutRiskCount;
1296
1646
  clearNode(riskEl);
1297
- riskEl.classList.toggle("panel-risk-attention", riskCount > 0);
1647
+ const dagRiskCount = dagAttentionRuns;
1648
+ const workerRiskCount = failuresCount + staleCount + timeoutRiskCount;
1649
+ riskEl.classList.toggle("panel-risk-attention", dagRiskCount > 0 || workerRiskCount > 0);
1298
1650
  riskEl.appendChild(el("h3", "section-heading", "运行风险"));
1299
- if (riskCount === 0) {
1651
+ if (dagRiskCount === 0 && workerRiskCount === 0) {
1300
1652
  riskEl.appendChild(el("p", "risk-empty", "当前没有需要处理的异常。"));
1301
1653
  } else {
1302
- riskEl.appendChild(el("div", "risk-total", String(riskCount)));
1303
- riskEl.appendChild(
1304
- el(
1305
- "p",
1306
- "risk-summary",
1307
- `失败 ${failuresCount} · 心跳失联 ${staleCount} · 即将超时 ${timeoutRiskCount}`,
1308
- ),
1309
- );
1310
- const link = el("a", "risk-link", "查看异常 Task →");
1311
- link.href = "#/failures";
1312
- link.addEventListener("click", (event) => {
1313
- event.preventDefault();
1314
- navigate("/failures");
1315
- });
1316
- riskEl.appendChild(link);
1654
+ const riskBlocks = el("div", "risk-blocks");
1655
+ if (dagRiskCount > 0) {
1656
+ const dagBlock = el("div", "risk-block risk-block-dag");
1657
+ dagBlock.appendChild(el("div", "risk-block-label", "需处理 DAG"));
1658
+ dagBlock.appendChild(el("div", "risk-total", String(dagRiskCount)));
1659
+ dagBlock.appendChild(el("p", "risk-summary",
1660
+ `去重运行数 ${dagAttentionRuns} · 中断 ${dagInterruptedRuns} · 失联 ${dagStaleRuns} · 状态不一致 ${dagInconsistentRuns} · 暂停 ${dagPausedRuns}`));
1661
+ riskBlocks.appendChild(dagBlock);
1662
+ }
1663
+ if (workerRiskCount > 0) {
1664
+ const workerBlock = el("div", "risk-block risk-block-worker");
1665
+ workerBlock.appendChild(el("div", "risk-block-label", "Worker 风险"));
1666
+ workerBlock.appendChild(el("div", "risk-total", String(workerRiskCount)));
1667
+ workerBlock.appendChild(el("p", "risk-summary",
1668
+ `失败 ${failuresCount} · 心跳失联 ${staleCount} · 即将超时 ${timeoutRiskCount}`));
1669
+ const link = el("a", "risk-link", "查看异常 Task →");
1670
+ link.href = "#/failures";
1671
+ link.addEventListener("click", (event) => {
1672
+ event.preventDefault();
1673
+ navigate("/failures");
1674
+ });
1675
+ workerBlock.appendChild(link);
1676
+ riskBlocks.appendChild(workerBlock);
1677
+ }
1678
+ riskEl.appendChild(riskBlocks);
1317
1679
  }
1318
1680
 
1319
1681
  clearNode(dagsEl);
@@ -1339,7 +1701,7 @@ async function renderDashboard(scrollTo) {
1339
1701
  rail.appendChild(
1340
1702
  el(
1341
1703
  "span",
1342
- `dag-run-timeline-dot status-${String(dag.status ?? "unknown").toLowerCase()}`,
1704
+ `dag-run-timeline-dot status-${badgeClass(dag.effectiveStatus ?? dag.status)}`,
1343
1705
  ),
1344
1706
  );
1345
1707
  item.appendChild(rail);
@@ -1356,7 +1718,7 @@ async function renderDashboard(scrollTo) {
1356
1718
  item.appendChild(content);
1357
1719
 
1358
1720
  const state = el("span", "dag-run-timeline-status");
1359
- state.appendChild(badge(dag.status));
1721
+ state.appendChild(dagStatusBadge(dag.effectiveStatus ?? dag.status));
1360
1722
  item.appendChild(state);
1361
1723
  timeline.appendChild(item);
1362
1724
  }
@@ -1559,7 +1921,7 @@ function renderRunDagProgress(task, snapshot) {
1559
1921
  panel.appendChild(
1560
1922
  metaGrid([
1561
1923
  ["DAG", dag.title || dag.dagRunId],
1562
- ["状态", badge(dag.status)],
1924
+ ["当前状态", dagStatusBadge(dag.effectiveStatus ?? dag.status)],
1563
1925
  ["进度", `${progress.finished}/${progress.total} 个节点`],
1564
1926
  [
1565
1927
  "详情",
@@ -1821,19 +2183,38 @@ async function renderDagDetail(dagRunId, initial = true) {
1821
2183
  .length;
1822
2184
 
1823
2185
  clearNode(metaEl);
1824
- metaEl.appendChild(
1825
- metaGrid([
1826
- ["标题", dag.title || dag.dagRunId],
1827
- ["DAG 运行 ID", dag.dagRunId],
1828
- ["状态", badge(dag.status)],
1829
- ["开始时间", formatTs(dag.startedAt)],
1830
- ["结束时间", formatTs(dag.finishedAt)],
1831
- ["总耗时", formatDuration(dag.durationMs)],
1832
- ["进度", `${progress.finished}/${progress.total}`],
1833
- ["成功/失败/跳过", `${succeededCount}/${failedCount}/${skippedCount}`],
1834
- ["DAG 路径", dag.dagPath ?? ""],
1835
- ]),
2186
+ const visibility = dagMetaVisibility(dag);
2187
+ const livenessValue = LIVENESS_LABELS[dag.liveness] ?? dag.liveness ?? LIVENESS_LABELS.unknown;
2188
+ const primaryEntries = [
2189
+ ["标题", dag.title || dag.dagRunId],
2190
+ ["DAG 运行 ID", dag.dagRunId],
2191
+ ["状态", dagStatusBadge(dag.effectiveStatus ?? dag.status)],
2192
+ ];
2193
+ if (visibility.showExecutorStatus) primaryEntries.push(["执行器状态", livenessValue]);
2194
+ if (visibility.showConsistencyWarning) primaryEntries.push(["状态一致性", "不一致,需要处理"]);
2195
+ if (visibility.showResume) primaryEntries.push(["允许恢复", "是"]);
2196
+ if (visibility.showReconcile) primaryEntries.push(["允许收口", ""]);
2197
+ primaryEntries.push(
2198
+ ["开始时间", formatTs(dag.startedAt)],
2199
+ ["结束时间", formatTs(dag.finishedAt)],
2200
+ ["总耗时", formatDuration(dag.durationMs)],
2201
+ ["进度", `${progress.finished}/${progress.total}`],
2202
+ ["成功/失败/跳过", `${succeededCount}/${failedCount}/${skippedCount}`],
2203
+ ["DAG 路径", dag.dagPath ?? "—"],
1836
2204
  );
2205
+ metaEl.appendChild(metaGrid(primaryEntries));
2206
+ const details = el("details", "meta-diagnostics");
2207
+ details.appendChild(el("summary", null, "诊断信息"));
2208
+ details.appendChild(metaGrid([
2209
+ ["当前判定", dagStatusBadge(dag.effectiveStatus ?? dag.status)],
2210
+ ["原始记录状态", badge(dag.status)],
2211
+ ["执行器状态", livenessValue],
2212
+ ["生命周期", dag.lifecycle ?? "—"],
2213
+ ["状态一致性", visibility.showConsistencyWarning ? "不一致,需要处理" : "一致"],
2214
+ ["允许恢复", visibility.showResume ? "是" : "否"],
2215
+ ["允许收口", visibility.showReconcile ? "是" : "否"],
2216
+ ]));
2217
+ metaEl.appendChild(details);
1837
2218
 
1838
2219
  clearNode(ranksEl);
1839
2220
  ranksEl.appendChild(el("h3", null, "DAG 依赖图"));
@@ -1955,6 +2336,7 @@ function route() {
1955
2336
  if (typeof window !== "undefined") {
1956
2337
  window.addEventListener("hashchange", route);
1957
2338
  window.addEventListener("load", route);
2339
+ document.addEventListener("pointerdown", closeDagInspectorOnOutsideClick);
1958
2340
  document.addEventListener("visibilitychange", () => {
1959
2341
  if (!isPageVisible()) return;
1960
2342
  const r = parseRoute();