@tea-agent/loop-agent 0.43.0-next.1 → 0.43.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/application/evaluation/budget.js +19 -1
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/executors/dag-pi-executor.js +762 -252
  5. package/dist/executors/pi-executor.js +20 -1
  6. package/dist/executors/pi-sdk-executor.js +64 -1
  7. package/dist/executors/shell-executor.js +5 -2
  8. package/dist/shared/frontend-execution-policy.js +22 -0
  9. package/dist/task/config-types.js +4 -0
  10. package/dist/task/source-prepare/ledger-reconciliation.js +2 -2
  11. package/dist/task/source-prepare/ledger-review.js +6 -9
  12. package/dist/task/source-prepare/semantic-intake.js +16 -26
  13. package/dist/task/source-prepare/source-fidelity-pi.js +26 -7
  14. package/dist/worker/observe/node-transparency.js +81 -72
  15. package/dist/worker/observe/routes.js +20 -1
  16. package/dist/worker/observe/static/dag-inspector-humanize.js +3 -0
  17. package/dist/worker/observe/static/dom.js +20 -1
  18. package/dist/worker/observe/static/format-pool.d.ts +2 -0
  19. package/dist/worker/observe/static/format-pool.js +6 -0
  20. package/dist/worker/observe/static/format.js +7 -0
  21. package/dist/worker/observe/static/inspect-workspace.js +34 -7
  22. package/dist/worker/observe/static/inspector-submission.js +32 -0
  23. package/dist/worker/observe/static/kpi.js +1 -0
  24. package/dist/worker/observe/static/relations.js +2 -0
  25. package/dist/worker/observe/static/router.js +13 -0
  26. package/dist/worker/observe/static/run-processing.js +2 -0
  27. package/dist/worker/observe/static/shell-chrome.js +36 -3
  28. package/dist/worker/observe/static/state.js +35 -2
  29. package/dist/worker/observe/static/styles.css +260 -39
  30. package/dist/worker/observe/static/task-failure-labels.d.ts +4 -0
  31. package/dist/worker/observe/static/task-failure-labels.js +67 -0
  32. package/dist/worker/observe/static/task-history.js +12 -0
  33. package/dist/worker/observe/static/views/batch.js +6 -13
  34. package/dist/worker/observe/static/views/dag-graph.js +50 -3
  35. package/dist/worker/observe/static/views/dag-inspector.js +746 -265
  36. package/dist/worker/observe/static/views/dag-trajectory.js +3 -0
  37. package/dist/worker/observe/static/views/dag.d.ts +6 -0
  38. package/dist/worker/observe/static/views/dag.js +48 -10
  39. package/dist/worker/observe/static/views/dags.js +2 -0
  40. package/dist/worker/observe/static/views/dashboard.js +21 -12
  41. package/dist/worker/observe/static/views/failures.js +21 -11
  42. package/dist/worker/observe/static/views/feature.js +11 -29
  43. package/dist/worker/observe/static/views/pool.js +37 -28
  44. package/dist/worker/observe/static/views/run.js +48 -5
  45. package/dist/worker/observe/static/views/session-timeline.js +189 -240
  46. package/dist/worker/observe/static/views/task.js +81 -62
  47. package/dist/workflows/dag/budget-enforcement.js +53 -3
  48. package/dist/workflows/dag/frontend-durable-tools.js +193 -0
  49. package/dist/workflows/dag/frontend-execution-groups.js +24 -0
  50. package/dist/workflows/dag/frontend-implementation-contract.js +9 -0
  51. package/dist/workflows/dag/frontend-input-projection.js +76 -0
  52. package/dist/workflows/dag/frontend-plan-render.js +10 -3
  53. package/dist/workflows/dag/frontend-recovery-controller.js +7 -7
  54. package/dist/workflows/dag/frontend-recovery-lineage.js +13 -0
  55. package/dist/workflows/dag/frontend-recovery-run.js +4 -0
  56. package/dist/workflows/dag/frontend-review-scopes.js +117 -0
  57. package/dist/workflows/dag/frontend-session-budget.js +249 -0
  58. package/dist/workflows/dag/frontend-shadow-dual-write.js +20 -2
  59. package/dist/workflows/dag/frontend-test-execution-evidence.js +3 -2
  60. package/dist/workflows/dag/frontend-typed-event-store.js +11 -0
  61. package/dist/workflows/dag/init-hybrid.js +16 -10
  62. package/dist/workflows/dag/node-execution.js +32 -155
  63. package/dist/workflows/dag/prompt.js +4 -0
  64. package/dist/workflows/dag/rerun-plan.js +7 -1
  65. package/dist/workflows/dag/runner.js +26 -1
  66. package/dist/workflows/dag/types.js +6 -0
  67. package/docs/operations/README.md +1 -0
  68. package/docs/templates/frontend-design-contract.md +4 -4
  69. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  70. package/docs/templates/frontend-implementation-dag.json +5 -5
  71. package/package.json +1 -1
  72. package/skills/frontend-contract/SKILL.md +2 -1
  73. package/skills/frontend-contract/references/contract-protocol.md +19 -3
  74. package/skills/frontend-design-review/SKILL.md +12 -11
  75. package/skills/frontend-plan/SKILL.md +2 -2
  76. package/skills/frontend-plan/references/decision-contract.md +18 -5
  77. package/skills/frontend-review/SKILL.md +10 -11
  78. package/skills/frontend-scout/references/scout-evidence.md +4 -0
@@ -100,6 +100,9 @@ function buildOccurrenceCard(dagRunId, occurrence) {
100
100
  false;
101
101
  const card = document.createElement("button");
102
102
  card.type = "button";
103
+ card.setAttribute("data-node-id", occurrence.nodeId);
104
+ card.setAttribute("data-execution-id", occurrence.executionId);
105
+ card.setAttribute("data-execution-key", occurrence.pass ? `pass-${occurrence.pass}` : "");
103
106
  const extraClasses = [
104
107
  recovered ? "is-recovered" : "",
105
108
  occurrence.availability === "partial" ? "is-partial" : "",
@@ -11,3 +11,9 @@ export function resolveNodeRemark(node: {
11
11
  nodeId: string;
12
12
  rank?: string | number | null;
13
13
  } | null | undefined): DagNodeRemark;
14
+
15
+ /** 面包屑次级:可读标题优先(截断),缺失/失败回退 run ID。 */
16
+ export function dagBreadcrumbLabel(
17
+ dag: { title?: string | null } | null | undefined,
18
+ dagRunId: string,
19
+ ): string;
@@ -43,6 +43,8 @@ import {
43
43
  } from "../router.js";
44
44
  import {
45
45
  fetchJson,
46
+ fetchJsonResult,
47
+ summarizeFetchError,
46
48
  artifactUrl,
47
49
  parseArtifactPreviewResponse,
48
50
  } from "../api.js";
@@ -67,7 +69,7 @@ import {
67
69
  mountViewState,
68
70
  makeSessionEventIdentity,
69
71
  } from "../state.js";
70
- import { uiState, dagGraphViewportStates, dagGraphViewModes } from "../state.js";
72
+ import { uiState, dagGraphViewportStates, dagGraphViewModes, inspectRouteStates } from "../state.js";
71
73
  import { sortPoolTasks } from "../format-pool.js";
72
74
  import {
73
75
  dagMetaVisibility,
@@ -82,6 +84,7 @@ import {
82
84
  showView,
83
85
  setBreadcrumb,
84
86
  updateHeaderRefresh,
87
+ bindManualViewRefresh,
85
88
  mountRelationBar,
86
89
  } from "../shell-chrome.js";
87
90
  import {
@@ -238,12 +241,20 @@ export function buildDagNodeTable(dagRunId, nodes) {
238
241
  return nodeTable;
239
242
  }
240
243
 
244
+ /** 面包屑次级:可读标题优先(截断),缺失/失败回退 run ID。 */
245
+ export function dagBreadcrumbLabel(dag, dagRunId) {
246
+ const title = typeof dag?.title === "string" ? dag.title.trim() : "";
247
+ return title ? truncateText(title, 48) : dagRunId;
248
+ }
249
+
241
250
  export async function renderDagDetail(dagRunId, initial = true) {
242
251
  showView("dag");
243
- setBreadcrumb([
252
+ const breadcrumbParts = (label) => [
244
253
  { label: UI_TEXT.dashboard, href: "#/" },
245
- { label: dagRunId },
246
- ]);
254
+ ...(inspectRouteStates.get("return-list") ? [{ label: "返回结果列表", href: inspectRouteStates.get("return-list") }] : []),
255
+ { label },
256
+ ];
257
+ setBreadcrumb(breadcrumbParts(dagRunId));
247
258
 
248
259
  if (initial || uiState.currentDagRunId !== dagRunId) {
249
260
  uiState.currentDagRunId = dagRunId;
@@ -274,13 +285,30 @@ export async function renderDagDetail(dagRunId, initial = true) {
274
285
  dagGraphViewportStates.set(dagRunId, uiState.dagGraphViewportState);
275
286
  }
276
287
 
277
- const [dag, snapshot] = await Promise.all([
278
- fetchJson(`/api/dag-runs/${encodeURIComponent(dagRunId)}`),
288
+ const requestGeneration = uiState.pollingGeneration;
289
+ const [dagResult, snapshot] = await Promise.all([
290
+ fetchJsonResult(`/api/dag-runs/${encodeURIComponent(dagRunId)}`),
279
291
  fetchJson("/api/snapshot"),
280
292
  ]);
293
+ if (requestGeneration !== uiState.pollingGeneration || uiState.currentDagRunId !== dagRunId) return false;
294
+ if (!dagResult.ok && dagResult.status !== 404) {
295
+ let error = metaEl.querySelector(".dag-refresh-error");
296
+ if (!error) { error = el("div", "dag-refresh-error"); metaEl.appendChild(error); }
297
+ clearNode(error);
298
+ error.appendChild(el("span", "node-input-error", `更新失败,保留上次内容。${summarizeFetchError(dagResult)}`));
299
+ const retry = el("button", "btn btn-secondary", "重试");
300
+ retry.type = "button";
301
+ retry.addEventListener("click", () => { void renderDagDetail(dagRunId, false); });
302
+ error.appendChild(retry);
303
+ return true;
304
+ }
305
+ metaEl.querySelector(".dag-refresh-error")?.remove();
306
+ const dag = dagResult.ok ? dagResult.body : null;
307
+ if (dag) setBreadcrumb(breadcrumbParts(dagBreadcrumbLabel(dag, dagRunId)));
281
308
  if (snapshot) {
282
309
  uiState.lastSnapshot = snapshot;
283
- updateHeaderRefresh(snapshot.generatedAt);
310
+ updateHeaderRefresh(snapshot.generatedAt, { mode: dag && isDagRunActive(dag) ? "auto" : "ended" });
311
+ bindManualViewRefresh("dag", () => renderDagDetail(dagRunId, false));
284
312
  }
285
313
  if (!dag) {
286
314
  clearNode(metaEl);
@@ -310,6 +338,16 @@ export async function renderDagDetail(dagRunId, initial = true) {
310
338
  );
311
339
 
312
340
  const nodes = dag.nodes ?? [];
341
+ if (initial) {
342
+ const routeState = parseRoute().inspector ?? inspectRouteStates.get(`dag:${dagRunId}`);
343
+ if (routeState?.nodeId && routeState.open !== false && nodes.some((node) => node.nodeId === routeState.nodeId)) {
344
+ uiState.selectedDagNodeId = routeState.nodeId;
345
+ uiState.dagInspectorOpen = true;
346
+ uiState.dagInspectorTab = ["overview", "input", "context", "timeline", "compare", "output", "lineage", "spec-evidence", "prompt-restart"].includes(routeState.tab) ? routeState.tab : "output";
347
+ uiState.dagInspectorAttempt = { dagRunId, nodeId: routeState.nodeId, key: routeState.executionKey };
348
+ uiState.sessionEventIdentity = makeSessionEventIdentity(dagRunId, routeState.nodeId);
349
+ }
350
+ }
313
351
  if (
314
352
  uiState.selectedDagNodeId &&
315
353
  !nodes.some((n) => n.nodeId === uiState.selectedDagNodeId)
@@ -330,7 +368,7 @@ export async function renderDagDetail(dagRunId, initial = true) {
330
368
  (n) => (n.status ?? "").toLowerCase() === "skipped",
331
369
  ).length;
332
370
  const succeededCount = nodes.filter(
333
- (n) => isNodeFinished(n.status) && !isNodeFailed(n.status),
371
+ (n) => isNodeFinished(n.status) && !isNodeFailed(n.status) && String(n.status ?? "").toLowerCase() !== "skipped",
334
372
  ).length;
335
373
 
336
374
  // reuse-dag-detail-shell: on a poll refresh (non-initial) we keep the
@@ -384,9 +422,8 @@ export async function renderDagDetail(dagRunId, initial = true) {
384
422
  ["开始时间", formatTs(dag.startedAt)],
385
423
  ["结束时间", formatTs(dag.finishedAt)],
386
424
  ["总耗时", formatDuration(dag.durationMs)],
387
- ["进度", `${progress.finished}/${progress.total}`],
425
+ ["已结束", `${progress.finished}/${progress.total}`],
388
426
  ["成功/失败/跳过", `${succeededCount}/${failedCount}/${skippedCount}`],
389
- ["记录保存位置", dag.dagPath ?? "—"],
390
427
  );
391
428
  const lintSummary = frontendLintSummary(dag.frontendLint);
392
429
  if (lintSummary) {
@@ -404,6 +441,7 @@ export async function renderDagDetail(dagRunId, initial = true) {
404
441
  ],
405
442
  ["允许恢复", visibility.showResume ? "是" : "否"],
406
443
  ["允许收口", visibility.showReconcile ? "是" : "否"],
444
+ ["记录保存位置", dag.dagPath ?? "—"],
407
445
  ]);
408
446
  if (!initial && existingDetails?.parentNode === metaEl) {
409
447
  const existingPrimary = metaEl.querySelector(":scope > .meta-grid");
@@ -1,3 +1,4 @@
1
+ import { isPlainNavigationClick, isRowNavigationClick } from "../router.js";
1
2
  /** Observe UI — formal DAG run history page (independent #/dags). */
2
3
  import { UI_TEXT } from "../constants.js";
3
4
  import { el, clearNode } from "../dom.js";
@@ -331,6 +332,7 @@ function buildRow(run) {
331
332
  `/dag/${encodeURIComponent(run.dagRunId)}`,
332
333
  );
333
334
  nameLink.addEventListener("click", (event) => {
335
+ if (!isPlainNavigationClick(event)) return;
334
336
  event.preventDefault();
335
337
  event.stopPropagation();
336
338
  navigate(`/dag/${encodeURIComponent(run.dagRunId)}`);
@@ -1,3 +1,4 @@
1
+ import { isRowNavigationClick } from "../router.js";
1
2
  /** Observe UI view module (R5). */
2
3
  import {
3
4
  UI_TEXT,
@@ -19,6 +20,7 @@ import {
19
20
  import { clearNode, el, reconcileKeyed, syncCompatibleNode } from "../dom.js";
20
21
  import {
21
22
  badge,
23
+ batchStatusBadge,
22
24
  badgeClass,
23
25
  statusLabel,
24
26
  formatTs,
@@ -287,16 +289,12 @@ export async function renderDashboard(scrollTo) {
287
289
  const dagStaleRuns = dagHealth?.staleRuns ?? 0;
288
290
  const dagInterruptedRuns = dagHealth?.interruptedRuns ?? 0;
289
291
  const dagInconsistentRuns = dagHealth?.inconsistentRuns ?? 0;
290
- const dagAttentionRuns =
291
- dagHealth?.attentionRuns ??
292
- dagRuns.filter(
293
- (dag) =>
294
- (dag.lifecycle ?? "").toLowerCase() !== "completed" &&
295
- ((dag.lifecycle ?? "").toLowerCase() === "paused" ||
296
- ["stale", "orphaned"].includes((dag.liveness ?? "").toLowerCase()) ||
297
- ["interrupted", "remote-unknown"].includes(dag.effectiveStatus) ||
298
- dag.stateConsistent === false),
299
- ).length;
292
+ const attentionDags = dagRuns.filter((dag) =>
293
+ ["paused", "failed", "error", "interrupted"].includes(String(dag.lifecycle ?? "").toLowerCase()) ||
294
+ ["failed", "error", "interrupted", "remote-unknown"].includes(String(dag.effectiveStatus ?? dag.status ?? "").toLowerCase()) ||
295
+ ["stale", "orphaned"].includes(String(dag.liveness ?? "").toLowerCase()) || dag.stateConsistent === false,
296
+ );
297
+ const dagAttentionRuns = attentionDags.length;
300
298
 
301
299
  const quietCount = snapshot.health?.quietCount ?? 0;
302
300
  const staleCount = snapshot.health?.staleCount ?? 0;
@@ -504,6 +502,7 @@ export async function renderDashboard(scrollTo) {
504
502
  dagBlock.appendChild(el("div", "risk-block-label", "需处理 DAG"));
505
503
  dagBlock.appendChild(el("div", "risk-total", String(block.total)));
506
504
  dagBlock.appendChild(el("p", "risk-summary", block.summary));
505
+ const details = el("details", "risk-dag-list"); details.appendChild(el("summary", "risk-link", "查看需处理运行")); details.appendChild(el("div", "risk-dag-items")); dagBlock.appendChild(details);
507
506
  return dagBlock;
508
507
  },
509
508
  update: (dagBlock, block) => {
@@ -511,6 +510,15 @@ export async function renderDashboard(scrollTo) {
511
510
  if (total) total.textContent = String(block.total);
512
511
  const summary = dagBlock.querySelector(".risk-summary");
513
512
  if (summary) summary.textContent = block.summary;
513
+ const list = dagBlock.querySelector(".risk-dag-items");
514
+ if (list) {
515
+ clearNode(list); for (const dag of attentionDags) {
516
+ const link = el("a", "object-link", dag.title || dag.dagRunId);
517
+ link.href = inspectPathToCanonicalHref(`/dag/${encodeURIComponent(dag.dagRunId)}`);
518
+ list.appendChild(link);
519
+ }
520
+ }
521
+
514
522
  },
515
523
  },
516
524
  );
@@ -634,7 +642,8 @@ export async function renderDashboard(scrollTo) {
634
642
  create: (batch) => {
635
643
  const tr = document.createElement("tr");
636
644
  tr.classList.add("clickable");
637
- tr.addEventListener("click", () =>
645
+ tr.addEventListener("click", (event) =>
646
+ isRowNavigationClick(event) &&
638
647
  navigate(`/batch/${encodeURIComponent(batch.batchRunId)}`),
639
648
  );
640
649
  return tr;
@@ -643,7 +652,7 @@ export async function renderDashboard(scrollTo) {
643
652
  const cells = [
644
653
  batch.batchRunId,
645
654
  batch.featureId ?? "—",
646
- badge(batch.status),
655
+ batchStatusBadge(batch),
647
656
  formatTs(batch.startedAt),
648
657
  summaryText(batch.summary),
649
658
  ];
@@ -1,3 +1,5 @@
1
+ import { bindManualViewRefresh, mountTaskRelationPicker } from "../shell-chrome.js";
2
+ import { sortAttentionTasks } from "../format-pool.js";
1
3
  /** Observe UI view module (R5). */
2
4
  import {
3
5
  UI_TEXT,
@@ -66,9 +68,23 @@ import {
66
68
  import { layoutDag } from "../dag-layout.js";
67
69
  import { showView, setBreadcrumb, updateHeaderRefresh } from "../shell-chrome.js";
68
70
  import { artifactLink } from "../kpi.js";
71
+ import { taskFailureCategoryLabel, followUpLabel } from "../task-failure-labels.js";
72
+
73
+ /** 异常页枚举单元格:中文主文案,raw token 保留在 title 供排障(未知原文透传)。 */
74
+ function attentionTokenCell(raw, kind) {
75
+ const value = String(raw ?? "").trim();
76
+ if (!value) return "—";
77
+ const kindName = kind === "category" ? "分类" : "跟进";
78
+ const mapped = kind === "category" ? taskFailureCategoryLabel(value) : followUpLabel(value);
79
+ const cell = el("span", "attention-token");
80
+ cell.textContent = mapped;
81
+ cell.setAttribute("title", mapped === value ? `未收录的${kindName} token:${value}` : `${kindName} token:${value}`);
82
+ return cell;
83
+ }
69
84
 
70
85
  export async function renderFailures() {
71
86
  showView("failures");
87
+ bindManualViewRefresh("failures", () => renderFailures());
72
88
  setBreadcrumb([
73
89
  { label: UI_TEXT.dashboard, href: "#/" },
74
90
  { label: UI_TEXT.failures },
@@ -89,15 +105,9 @@ export async function renderFailures() {
89
105
  return;
90
106
  }
91
107
  uiState.lastSnapshot = snapshot;
92
- updateHeaderRefresh(snapshot.generatedAt);
108
+ updateHeaderRefresh(snapshot.generatedAt, { mode: "manual" });
93
109
 
94
- const tasks = (snapshot.tasks ?? []).filter(
95
- (task) =>
96
- task.status === "failed" ||
97
- task.status === "blocked" ||
98
- task.status === "stale" ||
99
- task.liveness === "stale",
100
- );
110
+ const tasks = sortAttentionTasks(snapshot.tasks ?? []);
101
111
 
102
112
  if (tasks.length === 0) {
103
113
  clearNode(listEl);
@@ -122,11 +132,11 @@ export async function renderFailures() {
122
132
  ? () => navigate(`/run/${encodeURIComponent(task.workerRunId)}`)
123
133
  : undefined,
124
134
  cells: [
125
- task.taskId ?? "—",
135
+ objectRouteLink("task", task.taskId, snapshot, task.featureId),
126
136
  task.featureId ?? "—",
127
137
  badge(task.status),
128
- task.failureCategory ?? "",
129
- task.recommendedFollowUp ?? "",
138
+ attentionTokenCell(task.failureCategory, "category"),
139
+ attentionTokenCell(task.recommendedFollowUp, "followUp"),
130
140
  (() => {
131
141
  const links = el("div", "artifact-links");
132
142
  const r = refs(task);
@@ -1,3 +1,5 @@
1
+ import { renderInlineArtifactList } from "./run.js";
2
+ import { bindManualViewRefresh, mountTaskRelationPicker } from "../shell-chrome.js";
1
3
  /** Observe UI view module (R5). */
2
4
  import {
3
5
  UI_TEXT,
@@ -79,6 +81,7 @@ import { artifactLink } from "../kpi.js";
79
81
 
80
82
  export async function renderFeatureDetail(featureId) {
81
83
  showView("feature");
84
+ bindManualViewRefresh("feature", () => renderFeatureDetail(featureId));
82
85
  setBreadcrumb([
83
86
  { label: UI_TEXT.dashboard, href: "#/" },
84
87
  { label: featureId || "Feature" },
@@ -124,7 +127,7 @@ export async function renderFeatureDetail(featureId) {
124
127
  return false;
125
128
  }
126
129
  uiState.lastSnapshot = snapshot;
127
- updateHeaderRefresh(snapshot.generatedAt);
130
+ updateHeaderRefresh(snapshot.generatedAt, { mode: "manual" });
128
131
 
129
132
  const feature = lookup.kind === "found" ? lookup.feature : undefined;
130
133
  if (!feature) {
@@ -143,31 +146,8 @@ export async function renderFeatureDetail(featureId) {
143
146
  return false;
144
147
  }
145
148
 
146
- // Relations: only proven snapshot objects (feature itself + linked tasks if present).
147
- const linkedTask = (feature.tasks ?? [])
148
- .map((t) => t.taskId)
149
- .find((id) => objectExistsInSnapshot("task", id, snapshot));
150
- const poolTask = linkedTask
151
- ? (snapshot?.tasks ?? []).find((t) => t.taskId === linkedTask)
152
- : undefined;
153
- const batchRunId =
154
- poolTask?.batchRunId ??
155
- (poolTask ? findBatchRunIdForTask(snapshot, poolTask) : null);
156
- const dag =
157
- poolTask != null
158
- ? findDagForRun(snapshot, poolTask.workerRunId, poolTask)
159
- : undefined;
160
- mountRelationBar(
161
- "feature-relations",
162
- {
163
- featureId: feature.featureId,
164
- taskId: linkedTask,
165
- batchRunId: batchRunId ?? undefined,
166
- workerRunId: poolTask?.workerRunId,
167
- dagRunId: dag?.dagRunId ?? poolTask?.dagRunId,
168
- },
169
- snapshot,
170
- );
149
+ const linkedTasks = (feature.tasks ?? []).map((task) => ({ ...task, ...(snapshot.tasks ?? []).find((entry) => entry.taskId === task.taskId && entry.featureId === feature.featureId), featureId: feature.featureId }));
150
+ mountTaskRelationPicker("feature-relations", { featureId: feature.featureId }, linkedTasks, snapshot);
171
151
 
172
152
  try {
173
153
  metaEl.appendChild(el("h3", "section-heading", "状态与摘要"));
@@ -205,9 +185,11 @@ export async function renderFeatureDetail(featureId) {
205
185
  ac.required ? "是" : "否",
206
186
  ac.status ?? "—",
207
187
  (ac.blockedBy ?? []).join(", ") || "—",
208
- (ac.evidence ?? []).length
209
- ? String((ac.evidence ?? []).length)
210
- : "缺失",
188
+ (() => {
189
+ if (!(ac.evidence ?? []).length) return "缺失";
190
+ const details = el("details", "ac-evidence"); details.appendChild(el("summary", null, `${ac.evidence.length} 份证据`));
191
+ let loaded = false; details.addEventListener("toggle", () => { if (details.open && !loaded) { loaded = true; const body = el("div"); details.appendChild(body); renderInlineArtifactList(body, ac.evidence.map((path, index) => [`证据 ${index + 1}`, path])); } }); return details;
192
+ })(),
211
193
  ],
212
194
  })),
213
195
  ),
@@ -1,3 +1,4 @@
1
+ import { isPlainNavigationClick, isRowNavigationClick } from "../router.js";
1
2
  /** Observe UI view module — Task Pool ops surface (R5 + ops refresh). */
2
3
  import { UI_TEXT, POOL_POLL_MS } from "../constants.js";
3
4
  import { clearNode, el, reconcileKeyed } from "../dom.js";
@@ -26,6 +27,7 @@ import { uiState } from "../state.js";
26
27
  import {
27
28
  sortPoolTasks,
28
29
  matchesPoolStatusFilter,
30
+ matchesPoolTaskFilter,
29
31
  poolStatusCounts,
30
32
  sortAttentionTasks,
31
33
  collectPlannerReadyTasks,
@@ -125,6 +127,7 @@ function linkTo(path, label, className) {
125
127
  const a = el("a", className || "object-link", label);
126
128
  a.href = inspectPathToCanonicalHref(path);
127
129
  a.addEventListener("click", (e) => {
130
+ if (!isPlainNavigationClick(e)) return;
128
131
  e.preventDefault();
129
132
  e.stopPropagation();
130
133
  navigate(path);
@@ -264,7 +267,7 @@ export async function renderPool(options = {}) {
264
267
 
265
268
  if (updatedAtEl) {
266
269
  updatedAtEl.textContent = snapshot.generatedAt
267
- ? `投影更新:${formatTs(snapshot.generatedAt)}(advisory)`
270
+ ? `数据更新于 ${formatTs(snapshot.generatedAt)} · 仅提示`
268
271
  : "";
269
272
  }
270
273
 
@@ -302,21 +305,12 @@ export async function renderPool(options = {}) {
302
305
  const routeFilters = parseRoute().filters ?? {};
303
306
  const statusFilter = (routeFilters.status ?? "").toLowerCase();
304
307
  const qFilter = (routeFilters.q ?? "").trim().toLowerCase();
305
- const filteredTasks = allTasks.filter((task) => {
306
- if (statusFilter && !matchesPoolStatusFilter(task.status, statusFilter)) {
307
- return false;
308
- }
309
- if (qFilter) {
310
- const hay =
311
- `${task.taskId ?? ""} ${task.featureId ?? ""} ${task.workerRunId ?? ""} ${task.failureCategory ?? ""} ${task.recommendedFollowUp ?? ""}`.toLowerCase();
312
- if (!hay.includes(qFilter)) return false;
313
- }
314
- return true;
315
- });
308
+ const filteredTasks = allTasks.filter((task) => matchesPoolTaskFilter(task, routeFilters));
316
309
  const poolPresent = snapshot.taskPool?.present === true;
317
310
  const projectionFailed = Boolean(snapshot.projectionError);
318
- const readyItems = collectPlannerReadyTasks(snapshot.features);
319
- const attentionTasks = sortAttentionTasks(allTasks);
311
+ const taskById = new Map(allTasks.map((task) => [taskIdentityKey(task.featureId, task.taskId), task]));
312
+ const readyItems = collectPlannerReadyTasks(snapshot.features).filter((item) => matchesPoolTaskFilter({ ...item, status: "pending", ...taskById.get(taskIdentityKey(item.featureId, item.taskId)) }, routeFilters));
313
+ const attentionTasks = sortAttentionTasks(filteredTasks);
320
314
  const counts = poolStatusCounts(allTasks);
321
315
  const health = snapshot.health ?? {};
322
316
 
@@ -352,7 +346,7 @@ export async function renderPool(options = {}) {
352
346
  qLabel.appendChild(document.createTextNode("搜索"));
353
347
  const qInput = document.createElement("input");
354
348
  qInput.type = "search";
355
- qInput.placeholder = "Task / Feature / run…";
349
+ qInput.placeholder = "名称 / Task / Feature / run…";
356
350
  qInput.setAttribute("aria-label", "搜索 Task");
357
351
  qInput.dataset.role = "q";
358
352
  qLabel.appendChild(qInput);
@@ -366,14 +360,20 @@ export async function renderPool(options = {}) {
366
360
  }),
367
361
  );
368
362
  });
369
- qInput.addEventListener("change", () => {
370
- navigate(
371
- buildHashPath("/pool", {
372
- status: statusSelect.value || undefined,
373
- q: qInput.value.trim() || undefined,
374
- }),
375
- );
376
- });
363
+ let searchTimer;
364
+ const applySearch = () => {
365
+ clearTimeout(searchTimer);
366
+ searchTimer = setTimeout(() => {
367
+ if (parseRoute().view !== "pool" || !qInput.isConnected) return;
368
+ navigate(buildHashPath("/pool", { status: statusSelect.value, q: qInput.value.trim() }));
369
+ }, 220);
370
+ };
371
+ qInput.addEventListener("input", (event) => { if (!event.isComposing) applySearch(); });
372
+ qInput.addEventListener("compositionend", applySearch);
373
+ const clear = el("button", "btn btn-secondary", "清空筛选");
374
+ clear.type = "button";
375
+ clear.addEventListener("click", () => { clearTimeout(searchTimer); qInput.value = ""; statusSelect.value = ""; navigate("/pool"); });
376
+ wrap.appendChild(clear);
377
377
  wrap.appendChild(statusLabelEl);
378
378
  wrap.appendChild(qLabel);
379
379
  wrap.appendChild(count);
@@ -469,22 +469,31 @@ export async function renderPool(options = {}) {
469
469
  health.timeoutRiskCount ?? 0,
470
470
  (health.timeoutRiskCount ?? 0) > 0 ? "kpi-risk" : "",
471
471
  ],
472
- ["合计", allTasks.length, ""],
472
+ // 口径与 Ready 不同:Ready 来自 Feature 规划项,此处只数已建 Task。
473
+ [
474
+ "已建 Task",
475
+ allTasks.length,
476
+ "",
477
+ "当前已创建的 Task 数;上方 Ready 是 Feature 决策产生的规划项,尚未建 Task,不计入本数。",
478
+ ],
473
479
  ];
474
480
  reconcileKeyed(cards, entries, {
475
481
  getKey: ([label]) => label,
476
- create: ([label, value, extra]) => {
482
+ create: ([label, value, extra, title]) => {
477
483
  const card = el("div", `kpi-card${extra ? ` ${extra}` : ""}`);
478
484
  card.appendChild(el("div", "kpi-value", String(value)));
479
485
  card.appendChild(el("div", "kpi-label", label));
486
+ if (title) card.setAttribute("title", title);
480
487
  return card;
481
488
  },
482
- update: (card, [label, value, extra]) => {
489
+ update: (card, [label, value, extra, title]) => {
483
490
  card.className = `kpi-card${extra ? ` ${extra}` : ""}`;
484
491
  const valueEl = card.querySelector(".kpi-value");
485
492
  const labelEl = card.querySelector(".kpi-label");
486
493
  if (valueEl) valueEl.textContent = String(value);
487
494
  if (labelEl) labelEl.textContent = label;
495
+ if (title) card.setAttribute("title", title);
496
+ else card.removeAttribute("title");
488
497
  },
489
498
  });
490
499
  },
@@ -621,7 +630,7 @@ export async function renderPool(options = {}) {
621
630
  create: (task) => {
622
631
  const tr = document.createElement("tr");
623
632
  tr.classList.add("clickable");
624
- tr.addEventListener("click", () => navigate(taskPath(task)));
633
+ tr.addEventListener("click", (event) => { if (isRowNavigationClick(event)) navigate(taskPath(task)); });
625
634
  for (let i = 0; i < 8; i++) tr.appendChild(document.createElement("td"));
626
635
  return tr;
627
636
  },
@@ -703,7 +712,7 @@ export async function renderPool(options = {}) {
703
712
  export function startPoolView() {
704
713
  stopPoolAutoPolling();
705
714
  syncPoolToolbar();
706
- void renderPool({ reason: "route" }).then(() => {
715
+ return renderPool({ reason: "route" }).then(() => {
707
716
  if (poolAutoRefreshEnabled()) startPoolAutoPolling();
708
717
  });
709
718
  }
@@ -43,6 +43,8 @@ import {
43
43
  } from "../router.js";
44
44
  import {
45
45
  fetchJson,
46
+ fetchJsonResult,
47
+ summarizeFetchError,
46
48
  artifactUrl,
47
49
  parseArtifactPreviewResponse,
48
50
  } from "../api.js";
@@ -63,6 +65,7 @@ import {
63
65
  updateDagGraphViewportState,
64
66
  updateDagTimelineViewportState,
65
67
  computeFollowLatest,
68
+ isScrollRegionAboveBottom,
66
69
  updateDagOutputViewportState,
67
70
  renderViewState,
68
71
  mountViewState,
@@ -195,6 +198,32 @@ export function renderRunDagProgress(task, snapshot) {
195
198
  );
196
199
  }
197
200
 
201
+ /** 运行页「最新输出」的对话式「回到底部」按钮:可滚动且离开底部即出现,点击回到底部。 */
202
+ function buildRunOutputFollowDock(pre) {
203
+ const dock = el("div", "run-output-follow-dock");
204
+ const button = document.createElement("button");
205
+ button.type = "button";
206
+ button.className = "process-timeline-autoscroll";
207
+ button.setAttribute("aria-label", "回到底部");
208
+ button.setAttribute("data-tooltip", "回到底部");
209
+ const icon = el("i", "ri-arrow-down-line");
210
+ icon.setAttribute("aria-hidden", "true");
211
+ button.appendChild(icon);
212
+ const sync = () => {
213
+ dock.hidden = !isScrollRegionAboveBottom(pre);
214
+ };
215
+ pre._syncJumpToBottom = sync;
216
+ button.addEventListener("click", () => {
217
+ pre.scrollLeft = 0;
218
+ pre.scrollTop = pre.scrollHeight;
219
+ sync();
220
+ });
221
+ pre.addEventListener("scroll", sync, { passive: true });
222
+ sync();
223
+ dock.appendChild(button);
224
+ return dock;
225
+ }
226
+
198
227
  export function renderRunPanels(task, events, snapshot) {
199
228
  const metaEl = document.getElementById("run-meta");
200
229
  const stepsEl = document.getElementById("run-steps");
@@ -366,7 +395,10 @@ export function renderRunPanels(task, events, snapshot) {
366
395
  clearNode(outputEl);
367
396
  outputEl.appendChild(el("h3", null, "最新输出 (tail)"));
368
397
  pre = el("pre", "output-block", nextOutput);
369
- outputEl.appendChild(pre);
398
+ const shell = el("div", "run-output-shell");
399
+ shell.appendChild(pre);
400
+ shell.appendChild(buildRunOutputFollowDock(pre));
401
+ outputEl.appendChild(shell);
370
402
  } else if (pre.textContent !== nextOutput) {
371
403
  pre.textContent = nextOutput;
372
404
  }
@@ -376,6 +408,7 @@ export function renderRunPanels(task, events, snapshot) {
376
408
  pre.scrollTop = runOutputScrollTop;
377
409
  }
378
410
  pre.scrollLeft = runOutputScrollLeft;
411
+ pre._syncJumpToBottom?.();
379
412
  const runRegionKey = `run:${task.workerRunId ?? task.taskId}:output`;
380
413
  uiState.runOutputViewportState = updateDagOutputViewportState(
381
414
  uiState.runOutputViewportState,
@@ -424,12 +457,22 @@ export async function renderRun(workerRunId, initial = true) {
424
457
  return false;
425
458
  }
426
459
 
427
- const [task, snapshot] = await Promise.all([
428
- fetchJson(`/api/runs/${encodeURIComponent(workerRunId)}`),
460
+ const generation = uiState.pollingGeneration;
461
+ const [taskResult, snapshot] = await Promise.all([
462
+ fetchJsonResult(`/api/runs/${encodeURIComponent(workerRunId)}`),
429
463
  fetchJson("/api/snapshot"),
430
464
  ]);
465
+ if (generation !== uiState.pollingGeneration) return false;
466
+ const task = taskResult.ok ? taskResult.body : null;
467
+ if (!task && taskResult.status !== 404) {
468
+ const meta = document.getElementById("run-meta");
469
+ let error = meta?.querySelector(".run-refresh-error");
470
+ if (!error && meta) { error = el("p", "run-refresh-error node-input-error"); meta.appendChild(error); }
471
+ if (error) error.textContent = `更新失败,保留上次内容。${summarizeFetchError(taskResult)}`;
472
+ return true;
473
+ }
431
474
  if (!task) {
432
- if (initial) {
475
+ {
433
476
  const runMeta = document.getElementById("run-meta");
434
477
  if (runMeta) mountViewState(runMeta, "error", "找不到该运行。");
435
478
  }
@@ -438,7 +481,7 @@ export async function renderRun(workerRunId, initial = true) {
438
481
 
439
482
  if (snapshot) {
440
483
  uiState.lastSnapshot = snapshot;
441
- updateHeaderRefresh(snapshot.generatedAt);
484
+ updateHeaderRefresh(snapshot.generatedAt, { mode: TERMINAL_RUN_STATUSES.has(String(task.status ?? "").toLowerCase()) ? "ended" : "auto" });
442
485
  }
443
486
 
444
487
  uiState.currentBatchRunId = task.batchRunId ?? uiState.currentBatchRunId;