@tea-agent/loop-agent 0.13.0-beta.0 → 0.14.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 (127) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +56 -305
  3. package/README.md +13 -19
  4. package/dist/commands/init.js +92 -23
  5. package/dist/executors/pi-event-serializer.js +33 -11
  6. package/dist/executors/shell-executor.js +200 -21
  7. package/dist/infrastructure/evaluation/candidate-store.js +5 -1
  8. package/dist/worker/observe/spec-evidence.js +19 -10
  9. package/dist/worker/observe/static/app.js +4 -3
  10. package/dist/worker/observe/static/constants.js +10 -2
  11. package/dist/worker/observe/static/dag-helpers.js +37 -8
  12. package/dist/worker/observe/static/dom.js +159 -0
  13. package/dist/worker/observe/static/format-pool.d.ts +71 -0
  14. package/dist/worker/observe/static/format-pool.js +67 -0
  15. package/dist/worker/observe/static/format.js +27 -2
  16. package/dist/worker/observe/static/index.html +76 -34
  17. package/dist/worker/observe/static/kpi.js +12 -6
  18. package/dist/worker/observe/static/markdown-render.js +124 -0
  19. package/dist/worker/observe/static/shell-chrome.js +8 -2
  20. package/dist/worker/observe/static/state.js +20 -0
  21. package/dist/worker/observe/static/styles.css +662 -60
  22. package/dist/worker/observe/static/views/dag-inspector.js +65 -142
  23. package/dist/worker/observe/static/views/dag.js +9 -0
  24. package/dist/worker/observe/static/views/dashboard.js +512 -269
  25. package/dist/worker/observe/static/views/pool.js +595 -237
  26. package/dist/worker/observe/static/views/session-timeline.js +577 -11
  27. package/dist/workflows/dag/backend-test-case-manifest.js +503 -0
  28. package/dist/workflows/dag/backend-test-execution-contract.js +353 -0
  29. package/dist/workflows/dag/backend-test-result-contract.js +568 -0
  30. package/dist/workflows/dag/decision-envelope.js +57 -2
  31. package/dist/workflows/dag/frontend-implementation-contract.js +240 -0
  32. package/dist/workflows/dag/frontend-project-capability.js +309 -0
  33. package/dist/workflows/dag/frontend-repair.js +341 -0
  34. package/dist/workflows/dag/frontend-risk.js +161 -0
  35. package/dist/workflows/dag/frontend-verification-trace.js +190 -0
  36. package/dist/workflows/dag/init-hybrid.js +1020 -125
  37. package/dist/workflows/dag/repair-artifact.js +43 -3
  38. package/dist/workflows/dag/skill-instructions.js +4 -2
  39. package/dist/workflows/dag/types.js +29 -8
  40. package/docs/README.md +2 -0
  41. package/docs/agent-dag-recovery-playbook.md +3 -3
  42. package/docs/agent-dag-runner.md +3 -3
  43. package/docs/architecture/README.md +3 -3
  44. package/docs/architecture/dag-execution.md +1 -1
  45. package/docs/architecture/evolution.md +13 -13
  46. package/docs/architecture/facts-and-state.md +1 -1
  47. package/docs/architecture/runtime-boundaries.md +7 -7
  48. package/docs/architecture/system-overview.md +3 -3
  49. package/docs/architecture/worker-and-feature.md +3 -3
  50. package/docs/design/README.md +7 -7
  51. package/docs/development-principles.md +4 -4
  52. package/docs/exec-plans/active/README.md +2 -4
  53. package/docs/exec-plans/completed/README.md +29 -6
  54. package/docs/feature-workflow.md +57 -32
  55. package/docs/init-surface.manifest.json +21 -3
  56. package/docs/loop-agent-harness.md +8 -8
  57. package/docs/production-readiness.md +1 -1
  58. package/docs/progress/README.md +20 -3
  59. package/docs/reports/README.md +53 -7
  60. package/docs/templates/agent-dag.supervised-implementation.json +127 -8
  61. package/docs/templates/backend-test-case-manifest.schema.json +190 -0
  62. package/docs/templates/backend-test-dag.classify.prompt.md +75 -0
  63. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +6 -4
  64. package/docs/templates/backend-test-dag.json +269 -21
  65. package/docs/templates/backend-test-dag.retrospect.prompt.md +44 -30
  66. package/docs/templates/backend-test-dag.review-cases.prompt.md +6 -4
  67. package/docs/templates/backend-test-execution.schema.json +133 -0
  68. package/docs/templates/backend-test-result.schema.json +99 -0
  69. package/docs/templates/branch-merge-report.md +93 -0
  70. package/docs/templates/frontend-eval/fixtures/failures/01-type-build-error.md +17 -0
  71. package/docs/templates/frontend-eval/fixtures/failures/02-unit-component-test-fail.md +16 -0
  72. package/docs/templates/frontend-eval/fixtures/failures/03-fixture-schema-drift.md +16 -0
  73. package/docs/templates/frontend-eval/fixtures/failures/04-missing-loading-empty-error-state.md +16 -0
  74. package/docs/templates/frontend-eval/fixtures/failures/05-forbidden-write-writeset-expansion.md +16 -0
  75. package/docs/templates/frontend-eval/fixtures/failures/06-unapproved-dependency-add.md +16 -0
  76. package/docs/templates/frontend-eval/fixtures/failures/07-mock-production-on.md +21 -0
  77. package/docs/templates/frontend-eval/fixtures/functional/01-simple-component-style.md +29 -0
  78. package/docs/templates/frontend-eval/fixtures/functional/02-form-validation.md +28 -0
  79. package/docs/templates/frontend-eval/fixtures/functional/03-list-detail-page.md +28 -0
  80. package/docs/templates/frontend-eval/fixtures/functional/04-api-mock.md +29 -0
  81. package/docs/templates/frontend-eval/fixtures/functional/05-permission-auth-gated-ui.md +27 -0
  82. package/docs/templates/frontend-eval/fixtures/functional/06-ssr-server-client-boundary.md +28 -0
  83. package/docs/templates/frontend-eval/fixtures/functional/07-shared-public-component-api.md +28 -0
  84. package/docs/templates/frontend-eval/fixtures/functional/08-pure-local-no-remote.md +27 -0
  85. package/docs/templates/frontend-eval/metrics.md +138 -0
  86. package/docs/templates/frontend-eval/smoke-targets.md +53 -0
  87. package/docs/templates/frontend-implementation-contract.schema.json +27 -0
  88. package/docs/verification-matrix.md +1 -1
  89. package/examples/decision-gate-agent-dag.json +4 -4
  90. package/examples/hybrid-loop-agent-dag.json +1 -1
  91. package/package.json +2 -2
  92. package/skills/ai-engineering-context/SKILL.md +2 -2
  93. package/skills/browser-tools/SKILL.md +196 -0
  94. package/skills/browser-tools/browser-content.js +103 -0
  95. package/skills/browser-tools/browser-cookies.js +35 -0
  96. package/skills/browser-tools/browser-eval.js +53 -0
  97. package/skills/browser-tools/browser-hn-scraper.js +108 -0
  98. package/skills/browser-tools/browser-nav.js +44 -0
  99. package/skills/browser-tools/browser-pick.js +162 -0
  100. package/skills/browser-tools/browser-screenshot.js +34 -0
  101. package/skills/browser-tools/browser-start.js +86 -0
  102. package/skills/browser-tools/package-lock.json +2556 -0
  103. package/skills/browser-tools/package.json +19 -0
  104. package/skills/frontend-implementation/SKILL.md +3 -1
  105. package/skills/frontend-implementation/references/node-contracts.md +17 -66
  106. package/skills/frontend-verification/SKILL.md +1 -1
  107. package/skills/grill-with-docs/SKILL.md +5 -5
  108. package/skills/grill-with-docs/adr-format.md +3 -3
  109. package/skills/init-capability-evolution/SKILL.md +5 -5
  110. package/skills/loop-agent/SKILL.md +5 -5
  111. package/skills/loop-agent/references/README.md +3 -3
  112. package/skills/loop-agent/references/command-reference.md +39 -17
  113. package/skills/loop-agent/references/docs-converge.md +15 -15
  114. package/skills/loop-agent/references/harness-policy.md +2 -2
  115. package/skills/loop-agent/references/hybrid-dag.md +20 -15
  116. package/skills/loop-agent/references/multi-worktree.md +1 -1
  117. package/skills/loop-agent/references/orchestrator-and-interventions.md +8 -8
  118. package/skills/loop-agent/references/task-workflow.md +1 -1
  119. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -4
  120. package/skills/requesting-code-review/SKILL.md +1 -1
  121. package/skills/systematic-debugging/CREATION-LOG.md +3 -3
  122. package/skills/systematic-debugging/SKILL.md +1 -1
  123. package/skills/systematic-debugging/test-academic.md +1 -1
  124. package/skills/systematic-debugging/test-pressure-1.md +1 -1
  125. package/skills/systematic-debugging/test-pressure-2.md +1 -1
  126. package/skills/systematic-debugging/test-pressure-3.md +1 -1
  127. package/skills/verification-before-completion/SKILL.md +1 -1
@@ -1,124 +1,281 @@
1
- /** Observe UI view module (R5). */
2
- import {
3
- UI_TEXT,
4
- LIVENESS_LABELS,
5
- ARTIFACT_LABELS,
6
- ARTIFACT_KEYS,
7
- STEP_LABELS,
8
- STEP_ORDER,
9
- DAG_INSPECTOR_DEFAULT_WIDTH,
10
- DAG_INSPECTOR_MIN_WIDTH,
11
- DAG_INSPECTOR_MAX_WIDTH,
12
- DAG_INSPECTOR_RIGHT_OFFSET,
13
- DAG_INSPECTOR_VIEWPORT_MARGIN,
14
- DAG_INSPECTOR_KEYBOARD_STEP,
15
- TERMINAL_RUN_STATUSES,
16
- DAG_EFFECTIVE_STATUS_LABELS,
17
- STATUS_LABELS,
18
- } from "../constants.js";
19
- import { clearNode, el, metaGrid, buildTable } from "../dom.js";
1
+ /** Observe UI view module — Task Pool ops surface (R5 + ops refresh). */
2
+ import { UI_TEXT, POOL_POLL_MS } from "../constants.js";
3
+ import { clearNode, el, reconcileKeyed } from "../dom.js";
20
4
  import {
21
5
  badge,
22
- badgeClass,
23
- statusLabel,
24
- formatTs,
25
- formatMs,
26
- formatDuration,
27
6
  formatAgo,
28
- dagStatusBadge,
7
+ formatTs,
29
8
  livenessBadge,
30
- displayDurationMs,
31
- derivedDurationMs,
32
- summaryText,
33
9
  truncateText,
34
- repoBaseName,
35
- parseMarkdownBlocks,
36
- markdownLinkHref,
37
10
  } from "../format.js";
38
11
  import { parseRoute, navigate, buildHashPath } from "../router.js";
39
- import {
40
- fetchJson,
41
- artifactUrl,
42
- parseArtifactPreviewResponse,
43
- } from "../api.js";
44
- import {
45
- objectRouteLink,
46
- findFeatureInSnapshot,
47
- featureLookupState,
48
- buildObjectRelations,
49
- renderObjectRelationBar,
50
- } from "../relations.js";
51
- import { copyText, renderRecommendedCommand } from "../copy.js";
12
+ import { fetchJson } from "../api.js";
13
+ import { objectRouteLink } from "../relations.js";
52
14
  import {
53
15
  isPageVisible,
54
- detailPollDelay,
55
16
  dashboardPollDelay,
56
- captureDagGraphViewportState,
57
- updateDagGraphViewportState,
58
- updateDagTimelineViewportState,
59
- renderViewState,
60
17
  mountViewState,
18
+ renderViewState,
61
19
  } from "../state.js";
62
20
  import { uiState } from "../state.js";
63
21
  import {
64
22
  sortPoolTasks,
65
23
  matchesPoolStatusFilter,
66
24
  poolStatusCounts,
25
+ sortAttentionTasks,
26
+ collectPlannerReadyTasks,
27
+ taskIdentityKey,
67
28
  } from "../format-pool.js";
68
- import {
69
- dagMetaVisibility,
70
- isDagRunActive,
71
- dashboardVisibleFeatures,
72
- findDagForRun,
73
- } from "../dag-model.js";
74
- import { layoutDag } from "../dag-layout.js";
75
29
  import { showView, setBreadcrumb, updateHeaderRefresh } from "../shell-chrome.js";
76
30
  import { renderProjectionFault } from "../kpi.js";
77
31
  import { mountBoundedEventTimeline } from "./timeline.js";
78
32
 
79
- export async function renderPool() {
33
+ let poolToolbarBound = false;
34
+ let poolEventsBound = false;
35
+ let poolHasRendered = false;
36
+
37
+ function poolAutoRefreshEnabled() {
38
+ return uiState.poolAutoRefresh === true;
39
+ }
40
+
41
+ function syncPoolToolbar() {
42
+ const auto = document.getElementById("pool-auto-refresh");
43
+ const status = document.getElementById("pool-refresh-status");
44
+ if (auto) auto.checked = poolAutoRefreshEnabled();
45
+ if (status) {
46
+ status.textContent = poolAutoRefreshEnabled()
47
+ ? `自动刷新约 ${Math.round(POOL_POLL_MS / 1000)}s`
48
+ : "已关闭自动刷新";
49
+ }
50
+ }
51
+
52
+ function bindPoolToolbar() {
53
+ if (poolToolbarBound) return;
54
+ const btn = document.getElementById("pool-refresh-btn");
55
+ const auto = document.getElementById("pool-auto-refresh");
56
+ if (!btn || !auto) return;
57
+ poolToolbarBound = true;
58
+ btn.addEventListener("click", () => {
59
+ void renderPool({ reason: "manual" });
60
+ });
61
+ auto.addEventListener("change", () => {
62
+ uiState.poolAutoRefresh = auto.checked;
63
+ syncPoolToolbar();
64
+ if (auto.checked) startPoolAutoPolling();
65
+ else stopPoolAutoPolling();
66
+ });
67
+ }
68
+
69
+ function bindPoolEventsDetails() {
70
+ if (poolEventsBound) return;
71
+ const details = document.getElementById("pool-events-details");
72
+ if (!details) return;
73
+ poolEventsBound = true;
74
+ details.addEventListener("toggle", () => {
75
+ if (details.open) void refreshPoolEvents();
76
+ });
77
+ }
78
+
79
+ function stopPoolAutoPolling() {
80
+ if (uiState.poolTimer) {
81
+ clearTimeout(uiState.poolTimer);
82
+ uiState.poolTimer = null;
83
+ }
84
+ }
85
+
86
+ function startPoolAutoPolling() {
87
+ stopPoolAutoPolling();
88
+ if (!poolAutoRefreshEnabled()) return;
89
+ const generation = ++uiState.pollingGeneration;
90
+ const poll = async () => {
91
+ if (!poolAutoRefreshEnabled()) return;
92
+ await renderPool({ reason: "auto" });
93
+ if (generation !== uiState.pollingGeneration) return;
94
+ if (!poolAutoRefreshEnabled()) return;
95
+ const delay = isPageVisible() ? POOL_POLL_MS : dashboardPollDelay();
96
+ uiState.poolTimer = setTimeout(() => {
97
+ uiState.poolTimer = null;
98
+ void poll();
99
+ }, delay);
100
+ };
101
+ void poll();
102
+ }
103
+
104
+ async function refreshPoolEvents() {
105
+ const eventsEl = document.getElementById("pool-events");
106
+ const details = document.getElementById("pool-events-details");
107
+ if (!eventsEl || !details?.open) return;
108
+ await mountBoundedEventTimeline(eventsEl, "/api/events", "最近事件");
109
+ }
110
+
111
+ function taskPath(task) {
112
+ if (task.featureId) {
113
+ return `/feature/${encodeURIComponent(task.featureId)}/task/${encodeURIComponent(task.taskId)}`;
114
+ }
115
+ return `/task/${encodeURIComponent(task.taskId)}`;
116
+ }
117
+
118
+ function linkTo(path, label, className) {
119
+ const a = el("a", className || "object-link", label);
120
+ a.href = `#${path}`;
121
+ a.addEventListener("click", (e) => {
122
+ e.preventDefault();
123
+ e.stopPropagation();
124
+ navigate(path);
125
+ });
126
+ return a;
127
+ }
128
+
129
+ function syncCell(row, index, content) {
130
+ let td = row.children[index];
131
+ if (!td) {
132
+ td = document.createElement("td");
133
+ row.appendChild(td);
134
+ }
135
+ while (td.firstChild) td.removeChild(td.firstChild);
136
+ if (content instanceof Node) td.appendChild(content);
137
+ else td.textContent = content ?? "—";
138
+ }
139
+
140
+ function ensureTable(parent, headers, scope) {
141
+ let table = parent.querySelector("table");
142
+ if (!table) {
143
+ table = document.createElement("table");
144
+ const thead = document.createElement("thead");
145
+ const headRow = document.createElement("tr");
146
+ for (const h of headers) {
147
+ const th = document.createElement("th");
148
+ th.textContent = h;
149
+ headRow.appendChild(th);
150
+ }
151
+ thead.appendChild(headRow);
152
+ table.appendChild(thead);
153
+ table.appendChild(document.createElement("tbody"));
154
+ parent.appendChild(table);
155
+ }
156
+ return table.querySelector("tbody");
157
+ }
158
+
159
+ function renderOpsCard(item, kind) {
160
+ const card = el("div", `pool-ops-card pool-ops-card-${kind}`);
161
+ const title = el("div", "pool-ops-card-title");
162
+ title.appendChild(linkTo(taskPath(item), item.taskId));
163
+ card.appendChild(title);
164
+ const meta = el("div", "pool-ops-card-meta");
165
+ meta.appendChild(
166
+ el("span", null, item.featureId ? `Feature ${item.featureId}` : "Feature —"),
167
+ );
168
+ if (item.priority) meta.appendChild(el("span", null, item.priority));
169
+ if (item.status) meta.appendChild(badge(item.status));
170
+ else if (item.liveness) meta.appendChild(livenessBadge(item.liveness));
171
+ card.appendChild(meta);
172
+ const detail = item.recommendedFollowUp || item.title || item.failureCategory;
173
+ if (detail) {
174
+ card.appendChild(
175
+ el("p", "pool-ops-card-detail", truncateText(String(detail), 96)),
176
+ );
177
+ }
178
+ card.classList.add("clickable");
179
+ card.addEventListener("click", () => navigate(taskPath(item)));
180
+ return card;
181
+ }
182
+
183
+ function updateOpsCard(card, item) {
184
+ const title = card.querySelector(".pool-ops-card-title");
185
+ if (title) {
186
+ while (title.firstChild) title.removeChild(title.firstChild);
187
+ title.appendChild(linkTo(taskPath(item), item.taskId));
188
+ }
189
+ const meta = card.querySelector(".pool-ops-card-meta");
190
+ if (meta) {
191
+ while (meta.firstChild) meta.removeChild(meta.firstChild);
192
+ meta.appendChild(
193
+ el("span", null, item.featureId ? `Feature ${item.featureId}` : "Feature —"),
194
+ );
195
+ if (item.priority) meta.appendChild(el("span", null, item.priority));
196
+ if (item.status) meta.appendChild(badge(item.status));
197
+ else if (item.liveness) meta.appendChild(livenessBadge(item.liveness));
198
+ }
199
+ let detail = card.querySelector(".pool-ops-card-detail");
200
+ const text = item.recommendedFollowUp || item.title || item.failureCategory;
201
+ if (text) {
202
+ if (!detail) {
203
+ detail = el("p", "pool-ops-card-detail");
204
+ card.appendChild(detail);
205
+ }
206
+ detail.textContent = truncateText(String(text), 96);
207
+ } else if (detail) {
208
+ detail.remove();
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Mount or refresh the pool view. Default: no auto poll.
214
+ * @param {{ reason?: "route" | "manual" | "auto" }} [options]
215
+ */
216
+ export async function renderPool(options = {}) {
80
217
  showView("pool");
81
218
  setBreadcrumb([
82
219
  { label: UI_TEXT.dashboard, href: "#/" },
83
220
  { label: "资源池" },
84
221
  ]);
222
+ bindPoolToolbar();
223
+ bindPoolEventsDetails();
224
+ syncPoolToolbar();
85
225
 
86
226
  const faultEl = document.getElementById("pool-fault");
227
+ const updatedAtEl = document.getElementById("pool-updated-at");
87
228
  const kpiEl = document.getElementById("pool-kpi");
88
229
  const emptyEl = document.getElementById("pool-empty");
230
+ const attentionEl = document.getElementById("pool-attention");
231
+ const readyEl = document.getElementById("pool-ready");
89
232
  const listEl = document.getElementById("pool-tasks");
90
- const eventsEl = document.getElementById("pool-events");
91
- if (!faultEl || !kpiEl || !emptyEl || !listEl) return;
233
+ const filtersEl = document.getElementById("pool-filters");
234
+ if (!faultEl || !kpiEl || !emptyEl || !listEl || !attentionEl || !readyEl) {
235
+ return;
236
+ }
92
237
 
93
- mountViewState(faultEl, "loading", "加载资源池…");
94
- clearNode(kpiEl);
95
- clearNode(listEl);
96
- clearNode(emptyEl);
97
- emptyEl.hidden = true;
238
+ const firstPaint = !poolHasRendered;
239
+ if (firstPaint) {
240
+ faultEl.hidden = false;
241
+ faultEl.classList.add("pool-fault-active");
242
+ mountViewState(faultEl, "loading", "加载资源池…");
243
+ }
98
244
 
99
245
  const snapshot = await fetchJson("/api/snapshot");
100
246
  if (!snapshot) {
101
- clearNode(kpiEl);
102
- clearNode(listEl);
103
- clearNode(emptyEl);
104
- emptyEl.hidden = true;
105
- if (eventsEl) clearNode(eventsEl);
247
+ faultEl.hidden = false;
248
+ faultEl.classList.add("pool-fault-active");
106
249
  mountViewState(faultEl, "error", "无法加载 snapshot。");
250
+ if (updatedAtEl) updatedAtEl.textContent = "";
107
251
  return;
108
252
  }
109
253
  uiState.lastSnapshot = snapshot;
110
- updateHeaderRefresh(snapshot.generatedAt);
254
+ updateHeaderRefresh(snapshot.generatedAt, {
255
+ mode: poolAutoRefreshEnabled() ? "auto" : "manual",
256
+ });
257
+ poolHasRendered = true;
258
+
259
+ if (updatedAtEl) {
260
+ updatedAtEl.textContent = snapshot.generatedAt
261
+ ? `投影更新:${formatTs(snapshot.generatedAt)}(advisory)`
262
+ : "";
263
+ }
111
264
 
112
265
  clearNode(faultEl);
266
+ let hasFaultContent = false;
113
267
  if (snapshot.projectionError) {
114
- mountViewState(faultEl, "degraded", "投影降级:状态分布可能不完整。", {
115
- detail: snapshot.projectionError.message ?? null,
116
- });
117
- faultEl.appendChild(
118
- renderProjectionFault(snapshot.projectionError),
268
+ hasFaultContent = true;
269
+ const degraded = renderViewState(
270
+ "degraded",
271
+ "投影降级:状态分布可能不完整。",
272
+ { detail: snapshot.projectionError.message ?? null },
119
273
  );
274
+ faultEl.appendChild(degraded);
275
+ faultEl.appendChild(renderProjectionFault(snapshot.projectionError));
120
276
  }
121
277
  if ((snapshot.projectionWarnings ?? []).length > 0) {
278
+ hasFaultContent = true;
122
279
  const warn = el("div", "projection-fault");
123
280
  const body = el("div", "projection-fault-body");
124
281
  body.appendChild(el("p", "projection-fault-title", "投影警告"));
@@ -132,98 +289,110 @@ export async function renderPool() {
132
289
  warn.appendChild(body);
133
290
  faultEl.appendChild(warn);
134
291
  }
135
- if (snapshot.generatedAt) {
136
- faultEl.appendChild(
137
- el(
138
- "p",
139
- "muted pool-updated-at",
140
- `投影更新时间:${formatTs(snapshot.generatedAt)}(derived/advisory)`,
141
- ),
142
- );
143
- }
292
+ faultEl.hidden = !hasFaultContent;
293
+ faultEl.classList.toggle("pool-fault-active", hasFaultContent);
144
294
 
145
295
  const allTasks = sortPoolTasks(snapshot.tasks ?? []);
146
296
  const routeFilters = parseRoute().filters ?? {};
147
297
  const statusFilter = (routeFilters.status ?? "").toLowerCase();
148
298
  const qFilter = (routeFilters.q ?? "").trim().toLowerCase();
149
- const tasks = allTasks.filter((task) => {
299
+ const filteredTasks = allTasks.filter((task) => {
150
300
  if (statusFilter && !matchesPoolStatusFilter(task.status, statusFilter)) {
151
301
  return false;
152
302
  }
153
303
  if (qFilter) {
154
304
  const hay =
155
- `${task.taskId ?? ""} ${task.featureId ?? ""} ${task.workerRunId ?? ""} ${task.failureCategory ?? ""}`.toLowerCase();
305
+ `${task.taskId ?? ""} ${task.featureId ?? ""} ${task.workerRunId ?? ""} ${task.failureCategory ?? ""} ${task.recommendedFollowUp ?? ""}`.toLowerCase();
156
306
  if (!hay.includes(qFilter)) return false;
157
307
  }
158
308
  return true;
159
309
  });
160
310
  const poolPresent = snapshot.taskPool?.present === true;
161
311
  const projectionFailed = Boolean(snapshot.projectionError);
312
+ const readyItems = collectPlannerReadyTasks(snapshot.features);
313
+ const attentionTasks = sortAttentionTasks(allTasks);
314
+ const counts = poolStatusCounts(allTasks);
315
+ const health = snapshot.health ?? {};
162
316
 
163
- // Shareable hash filters (REQ-R5-04)
164
- if (listEl) {
165
- let filtersEl = document.getElementById("pool-filters");
166
- if (!filtersEl) {
167
- filtersEl = el("div", "pool-filters");
168
- filtersEl.id = "pool-filters";
169
- listEl.parentElement?.insertBefore(filtersEl, listEl);
170
- }
171
- clearNode(filtersEl);
172
- const statusLabelEl = el("label", null);
173
- statusLabelEl.appendChild(document.createTextNode("状态"));
174
- const statusSelect = document.createElement("select");
175
- statusSelect.setAttribute("aria-label", "按状态筛选 Task");
176
- for (const [value, label] of [
177
- ["", "全部"],
178
- ["running", "运行中"],
179
- ["pending", "等待/排队"],
180
- ["blocked", "阻塞/失联"],
181
- ["failed", "失败"],
182
- ["succeeded", "成功/复用"],
183
- ]) {
184
- const opt = document.createElement("option");
185
- opt.value = value;
186
- opt.textContent = label;
187
- if (value === statusFilter) opt.selected = true;
188
- statusSelect.appendChild(opt);
189
- }
190
- statusLabelEl.appendChild(statusSelect);
191
- const qLabel = el("label", null);
192
- qLabel.appendChild(document.createTextNode("搜索"));
193
- const qInput = document.createElement("input");
194
- qInput.type = "search";
195
- qInput.placeholder = "Task / Feature / run…";
196
- qInput.setAttribute("aria-label", "搜索 Task");
197
- qInput.value = routeFilters.q ?? "";
198
- qLabel.appendChild(qInput);
199
- statusSelect.addEventListener("change", () => {
200
- const next = buildHashPath("/pool", {
201
- status: statusSelect.value || undefined,
202
- q: qInput.value.trim() || undefined,
203
- });
204
- navigate(next);
205
- });
206
- qInput.addEventListener("change", () => {
207
- const next = buildHashPath("/pool", {
208
- status: statusSelect.value || undefined,
209
- q: qInput.value.trim() || undefined,
210
- });
211
- navigate(next);
212
- });
213
- filtersEl.appendChild(statusLabelEl);
214
- filtersEl.appendChild(qLabel);
215
- filtersEl.appendChild(
216
- el("span", "muted layer-raw", `显示 ${tasks.length}/${allTasks.length}`),
317
+ // Filters (stable container in HTML)
318
+ if (filtersEl) {
319
+ reconcileKeyed(
320
+ filtersEl,
321
+ [{ key: "__filters__" }],
322
+ {
323
+ getKey: (item) => item.key,
324
+ create: () => {
325
+ const wrap = el("div", "pool-filters-inner");
326
+ const statusLabelEl = el("label", null);
327
+ statusLabelEl.appendChild(document.createTextNode("状态"));
328
+ const statusSelect = document.createElement("select");
329
+ statusSelect.setAttribute("aria-label", "按状态筛选 Task");
330
+ statusSelect.dataset.role = "status";
331
+ for (const [value, label] of [
332
+ ["", "全部"],
333
+ ["running", "运行中"],
334
+ ["pending", "等待/排队"],
335
+ ["blocked", "阻塞/失联"],
336
+ ["failed", "失败"],
337
+ ["succeeded", "成功/复用"],
338
+ ]) {
339
+ const opt = document.createElement("option");
340
+ opt.value = value;
341
+ opt.textContent = label;
342
+ statusSelect.appendChild(opt);
343
+ }
344
+ statusLabelEl.appendChild(statusSelect);
345
+ const qLabel = el("label", null);
346
+ qLabel.appendChild(document.createTextNode("搜索"));
347
+ const qInput = document.createElement("input");
348
+ qInput.type = "search";
349
+ qInput.placeholder = "Task / Feature / run…";
350
+ qInput.setAttribute("aria-label", "搜索 Task");
351
+ qInput.dataset.role = "q";
352
+ qLabel.appendChild(qInput);
353
+ const count = el("span", "muted layer-raw");
354
+ count.dataset.role = "count";
355
+ statusSelect.addEventListener("change", () => {
356
+ navigate(
357
+ buildHashPath("/pool", {
358
+ status: statusSelect.value || undefined,
359
+ q: qInput.value.trim() || undefined,
360
+ }),
361
+ );
362
+ });
363
+ qInput.addEventListener("change", () => {
364
+ navigate(
365
+ buildHashPath("/pool", {
366
+ status: statusSelect.value || undefined,
367
+ q: qInput.value.trim() || undefined,
368
+ }),
369
+ );
370
+ });
371
+ wrap.appendChild(statusLabelEl);
372
+ wrap.appendChild(qLabel);
373
+ wrap.appendChild(count);
374
+ return wrap;
375
+ },
376
+ update: (wrap) => {
377
+ const statusSelect = wrap.querySelector('[data-role="status"]');
378
+ const qInput = wrap.querySelector('[data-role="q"]');
379
+ const count = wrap.querySelector('[data-role="count"]');
380
+ if (statusSelect && statusSelect.value !== statusFilter) {
381
+ statusSelect.value = statusFilter;
382
+ }
383
+ if (qInput && document.activeElement !== qInput) {
384
+ qInput.value = routeFilters.q ?? "";
385
+ }
386
+ if (count) {
387
+ count.textContent = `显示 ${filteredTasks.length}/${allTasks.length}`;
388
+ }
389
+ },
390
+ },
217
391
  );
218
392
  }
219
393
 
220
- clearNode(kpiEl);
221
- clearNode(listEl);
222
- emptyEl.hidden = true;
223
- clearNode(emptyEl);
224
-
225
394
  if (projectionFailed) {
226
- // Fault-first: do not render healthy-looking zero KPIs when projection is broken.
395
+ clearNode(kpiEl);
227
396
  kpiEl.appendChild(
228
397
  el(
229
398
  "p",
@@ -231,120 +400,309 @@ export async function renderPool() {
231
400
  "投影失败,状态分布与健康 KPI 不可用(避免将未知显示为健康 0)。",
232
401
  ),
233
402
  );
234
- if (eventsEl) {
235
- await mountBoundedEventTimeline(
236
- eventsEl,
237
- "/api/events",
238
- "最近事件",
239
- );
240
- }
403
+ clearNode(attentionEl);
404
+ clearNode(readyEl);
405
+ clearNode(listEl);
406
+ emptyEl.hidden = true;
407
+ await refreshPoolEvents();
241
408
  return;
242
409
  }
243
410
 
244
- if (!poolPresent && tasks.length === 0) {
411
+ if (!poolPresent && allTasks.length === 0) {
412
+ clearNode(kpiEl);
413
+ clearNode(attentionEl);
414
+ clearNode(readyEl);
415
+ clearNode(listEl);
245
416
  emptyEl.hidden = false;
246
417
  mountViewState(
247
418
  emptyEl,
248
419
  "empty",
249
420
  "当前仓库未使用 Task Pool(未发现 .harness/task-pool)。",
250
421
  );
251
- if (eventsEl) {
252
- await mountBoundedEventTimeline(
253
- eventsEl,
254
- "/api/events",
255
- "最近事件",
256
- );
257
- }
422
+ await refreshPoolEvents();
258
423
  return;
259
424
  }
260
425
 
261
- if (poolPresent && tasks.length === 0) {
262
- emptyEl.hidden = false;
426
+ emptyEl.hidden = !(poolPresent && allTasks.length === 0);
427
+ if (!emptyEl.hidden) {
263
428
  mountViewState(emptyEl, "empty", "Task Pool 已存在,但当前没有 Task。");
264
429
  }
265
430
 
266
- const counts = poolStatusCounts(tasks);
267
- const cards = el("div", "kpi-grid");
268
- for (const [label, value] of [
269
- ["运行中", counts.running],
270
- ["等待/排队", counts.pending],
271
- ["阻塞/失联", counts.blocked],
272
- ["失败", counts.failed],
273
- ["成功/复用", counts.succeeded],
274
- ["其他", counts.other],
275
- ["合计", tasks.length],
276
- ]) {
277
- const card = el("div", "kpi-card");
278
- card.appendChild(el("div", "kpi-value", String(value)));
279
- card.appendChild(el("div", "kpi-label", label));
280
- cards.appendChild(card);
281
- }
282
- kpiEl.appendChild(el("h3", "section-heading", "状态分布"));
283
- kpiEl.appendChild(cards);
284
-
285
- if (tasks.length > 0) {
286
- const rows = tasks.map((task) => {
287
- const taskLink = document.createElement("a");
288
- const taskPath = task.featureId
289
- ? `/feature/${encodeURIComponent(task.featureId)}/task/${encodeURIComponent(task.taskId)}`
290
- : `/task/${encodeURIComponent(task.taskId)}`;
291
- taskLink.href = `#${taskPath}`;
292
- taskLink.textContent = task.taskId;
293
- taskLink.addEventListener("click", (e) => {
294
- e.preventDefault();
295
- navigate(taskPath);
431
+ // L1 health — always from full pool, not filtered set
432
+ reconcileKeyed(kpiEl, [{ key: "__heading__" }], {
433
+ getKey: () => "__heading__",
434
+ scope: "heading",
435
+ create: () => el("h3", "section-heading", "池健康"),
436
+ update: () => {},
437
+ });
438
+ reconcileKeyed(kpiEl, [{ key: "__cards__" }], {
439
+ getKey: () => "__cards__",
440
+ scope: "cards",
441
+ create: () => el("div", "kpi-grid pool-health-grid"),
442
+ update: (cards) => {
443
+ const entries = [
444
+ ["运行中", counts.running, counts.running > 0 ? "kpi-highlight" : ""],
445
+ [
446
+ "阻塞/失联",
447
+ counts.blocked,
448
+ counts.blocked > 0 ? "kpi-risk" : "",
449
+ ],
450
+ ["失败", counts.failed, counts.failed > 0 ? "kpi-danger" : ""],
451
+ [
452
+ "Ready",
453
+ readyItems.length,
454
+ readyItems.length > 0 ? "kpi-highlight" : "",
455
+ ],
456
+ [
457
+ "无输出",
458
+ health.quietCount ?? 0,
459
+ (health.quietCount ?? 0) > 0 ? "kpi-muted" : "",
460
+ ],
461
+ [
462
+ "即将超时",
463
+ health.timeoutRiskCount ?? 0,
464
+ (health.timeoutRiskCount ?? 0) > 0 ? "kpi-risk" : "",
465
+ ],
466
+ ["合计", allTasks.length, ""],
467
+ ];
468
+ reconcileKeyed(cards, entries, {
469
+ getKey: ([label]) => label,
470
+ create: ([label, value, extra]) => {
471
+ const card = el("div", `kpi-card${extra ? ` ${extra}` : ""}`);
472
+ card.appendChild(el("div", "kpi-value", String(value)));
473
+ card.appendChild(el("div", "kpi-label", label));
474
+ return card;
475
+ },
476
+ update: (card, [label, value, extra]) => {
477
+ card.className = `kpi-card${extra ? ` ${extra}` : ""}`;
478
+ const valueEl = card.querySelector(".kpi-value");
479
+ const labelEl = card.querySelector(".kpi-label");
480
+ if (valueEl) valueEl.textContent = String(value);
481
+ if (labelEl) labelEl.textContent = label;
482
+ },
296
483
  });
484
+ },
485
+ });
297
486
 
298
- let runCell = "—";
299
- if (task.workerRunId) {
300
- const a = document.createElement("a");
301
- a.href = `#/run/${encodeURIComponent(task.workerRunId)}`;
302
- a.textContent = task.workerRunId;
303
- a.addEventListener("click", (e) => {
304
- e.preventDefault();
305
- navigate(`#/run/${encodeURIComponent(task.workerRunId)}`);
487
+ // L2 attention
488
+ reconcileKeyed(attentionEl, [{ key: "__heading__" }], {
489
+ getKey: () => "__heading__",
490
+ scope: "heading",
491
+ create: () => el("h3", "section-heading", "需处理"),
492
+ update: () => {},
493
+ });
494
+ reconcileKeyed(
495
+ attentionEl,
496
+ attentionTasks.length === 0
497
+ ? [{ key: "__empty__", text: "当前没有需要处理的 Task。" }]
498
+ : [{ key: "__list__" }],
499
+ {
500
+ getKey: (item) => item.key,
501
+ scope: "body",
502
+ create: (item) => {
503
+ if (item.key === "__empty__") return el("p", "empty", item.text);
504
+ return el("div", "pool-ops-list");
505
+ },
506
+ update: (node, item) => {
507
+ if (item.key === "__empty__") {
508
+ node.textContent = item.text;
509
+ return;
510
+ }
511
+ reconcileKeyed(node, attentionTasks, {
512
+ getKey: (task) => taskIdentityKey(task.featureId, task.taskId),
513
+ create: (task) => renderOpsCard(task, "attention"),
514
+ update: (card, task) => updateOpsCard(card, task),
306
515
  });
307
- runCell = a;
308
- }
309
-
310
- return {
311
- cells: [
312
- taskLink,
313
- task.featureId ?? "",
314
- badge(task.status),
315
- runCell,
316
- formatAgo(task.updatedAt || task.finishedAt || task.startedAt),
317
- task.failureCategory ?? "—",
318
- ],
319
- };
320
- });
321
- listEl.appendChild(
322
- buildTable(
323
- ["Task", "Feature", "状态", "最新 worker run", "更新时间", "失败分类"],
324
- rows,
325
- ),
516
+ },
517
+ },
518
+ );
519
+
520
+ // L2 Ready — planner selected only
521
+ reconcileKeyed(readyEl, [{ key: "__heading__" }], {
522
+ getKey: () => "__heading__",
523
+ scope: "heading",
524
+ create: () => el("h3", "section-heading", "Ready(Planner)"),
525
+ update: () => {},
526
+ });
527
+ reconcileKeyed(
528
+ readyEl,
529
+ readyItems.length === 0
530
+ ? [{ key: "__empty__", text: "当前没有 Planner 选中的 Ready Task。" }]
531
+ : [{ key: "__list__" }],
532
+ {
533
+ getKey: (item) => item.key,
534
+ scope: "body",
535
+ create: (item) => {
536
+ if (item.key === "__empty__") return el("p", "empty", item.text);
537
+ return el("div", "pool-ops-list");
538
+ },
539
+ update: (node, item) => {
540
+ if (item.key === "__empty__") {
541
+ node.textContent = item.text;
542
+ return;
543
+ }
544
+ const taskByKey = new Map(
545
+ allTasks.map((t) => [taskIdentityKey(t.featureId, t.taskId), t]),
546
+ );
547
+ reconcileKeyed(node, readyItems, {
548
+ getKey: (ready) => taskIdentityKey(ready.featureId, ready.taskId),
549
+ create: (ready) => {
550
+ const live = taskByKey.get(
551
+ taskIdentityKey(ready.featureId, ready.taskId),
552
+ );
553
+ return renderOpsCard(
554
+ {
555
+ ...ready,
556
+ status: live?.status ?? "pending",
557
+ recommendedFollowUp: live?.recommendedFollowUp,
558
+ },
559
+ "ready",
560
+ );
561
+ },
562
+ update: (card, ready) => {
563
+ const live = taskByKey.get(
564
+ taskIdentityKey(ready.featureId, ready.taskId),
565
+ );
566
+ updateOpsCard(card, {
567
+ ...ready,
568
+ status: live?.status ?? "pending",
569
+ recommendedFollowUp: live?.recommendedFollowUp,
570
+ });
571
+ },
572
+ });
573
+ },
574
+ },
575
+ );
576
+
577
+ // L3 inventory
578
+ reconcileKeyed(listEl, [{ key: "__heading__" }], {
579
+ getKey: () => "__heading__",
580
+ scope: "heading",
581
+ create: () => el("h3", "section-heading", "全部 Task"),
582
+ update: () => {},
583
+ });
584
+ if (filteredTasks.length === 0) {
585
+ reconcileKeyed(
586
+ listEl,
587
+ [{ key: "__empty__", text: "没有匹配筛选条件的 Task。" }],
588
+ {
589
+ getKey: (item) => item.key,
590
+ scope: "table",
591
+ create: (item) => el("p", "empty", item.text),
592
+ update: (node, item) => {
593
+ node.textContent = item.text;
594
+ },
595
+ },
326
596
  );
597
+ } else {
598
+ reconcileKeyed(listEl, [{ key: "__table__" }], {
599
+ getKey: () => "__table__",
600
+ scope: "table",
601
+ create: () => el("div", "pool-table-wrap"),
602
+ update: (wrap) => {
603
+ const tbody = ensureTable(wrap, [
604
+ "Task",
605
+ "Feature",
606
+ "状态",
607
+ "存活",
608
+ "最新 run",
609
+ "Batch / DAG",
610
+ "跟进",
611
+ "更新",
612
+ ]);
613
+ reconcileKeyed(tbody, filteredTasks, {
614
+ getKey: (task) => taskIdentityKey(task.featureId, task.taskId),
615
+ create: (task) => {
616
+ const tr = document.createElement("tr");
617
+ tr.classList.add("clickable");
618
+ tr.addEventListener("click", () => navigate(taskPath(task)));
619
+ for (let i = 0; i < 8; i++) tr.appendChild(document.createElement("td"));
620
+ return tr;
621
+ },
622
+ update: (tr, task) => {
623
+ syncCell(tr, 0, linkTo(taskPath(task), task.taskId));
624
+ syncCell(
625
+ tr,
626
+ 1,
627
+ task.featureId
628
+ ? objectRouteLink("feature", task.featureId, snapshot)
629
+ : "—",
630
+ );
631
+ syncCell(tr, 2, badge(task.status));
632
+ syncCell(
633
+ tr,
634
+ 3,
635
+ task.liveness ? livenessBadge(task.liveness) : "—",
636
+ );
637
+ syncCell(
638
+ tr,
639
+ 4,
640
+ task.workerRunId
641
+ ? linkTo(
642
+ `/run/${encodeURIComponent(task.workerRunId)}`,
643
+ task.workerRunId,
644
+ )
645
+ : "—",
646
+ );
647
+ const links = el("span", "pool-ref-links");
648
+ if (task.batchRunId) {
649
+ links.appendChild(
650
+ linkTo(
651
+ `/batch/${encodeURIComponent(task.batchRunId)}`,
652
+ task.batchRunId,
653
+ ),
654
+ );
655
+ }
656
+ if (task.dagRunId) {
657
+ if (links.childNodes.length) {
658
+ links.appendChild(document.createTextNode(" · "));
659
+ }
660
+ links.appendChild(
661
+ linkTo(
662
+ `/dag/${encodeURIComponent(task.dagRunId)}`,
663
+ task.dagRunId,
664
+ ),
665
+ );
666
+ }
667
+ syncCell(
668
+ tr,
669
+ 5,
670
+ links.childNodes.length ? links : "—",
671
+ );
672
+ syncCell(
673
+ tr,
674
+ 6,
675
+ task.recommendedFollowUp || task.failureCategory || "—",
676
+ );
677
+ syncCell(
678
+ tr,
679
+ 7,
680
+ formatAgo(task.updatedAt || task.finishedAt || task.startedAt),
681
+ );
682
+ },
683
+ });
684
+ },
685
+ });
327
686
  }
328
687
 
329
- if (eventsEl) {
330
- await mountBoundedEventTimeline(
331
- eventsEl,
332
- "/api/events",
333
- "最近事件",
334
- );
688
+ if (options.reason === "manual" || options.reason === "auto") {
689
+ await refreshPoolEvents();
690
+ } else {
691
+ // Route entry: only load events if the operator already expanded them.
692
+ await refreshPoolEvents();
335
693
  }
336
694
  }
337
695
 
696
+ /** Enter pool view; auto-refresh stays off unless the user already enabled it. */
697
+ export function startPoolView() {
698
+ stopPoolAutoPolling();
699
+ syncPoolToolbar();
700
+ void renderPool({ reason: "route" }).then(() => {
701
+ if (poolAutoRefreshEnabled()) startPoolAutoPolling();
702
+ });
703
+ }
704
+
705
+ /** @deprecated Use startPoolView — kept as alias for older call sites/tests. */
338
706
  export function startPoolPolling() {
339
- if (uiState.poolTimer) clearTimeout(uiState.poolTimer);
340
- const generation = ++uiState.pollingGeneration;
341
- const poll = async () => {
342
- await renderPool();
343
- if (generation !== uiState.pollingGeneration) return;
344
- uiState.poolTimer = setTimeout(() => {
345
- uiState.poolTimer = null;
346
- void poll();
347
- }, dashboardPollDelay());
348
- };
349
- void poll();
707
+ startPoolView();
350
708
  }