@tea-agent/loop-agent 0.12.0 → 0.13.0-alpha.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 (79) hide show
  1. package/CHANGELOG.md +50 -2
  2. package/README.md +30 -2
  3. package/dist/application/dag/generate-task-dag.js +30 -0
  4. package/dist/cli/command-definitions.js +10 -3
  5. package/dist/commands/knowledge.js +129 -31
  6. package/dist/governance/manifest-types.js +3 -0
  7. package/dist/task/config-types.js +5 -1
  8. package/dist/worker/cli.js +96 -1
  9. package/dist/worker/delivery/package.js +3 -3
  10. package/dist/worker/feature/decision-loader.js +37 -6
  11. package/dist/worker/feature/next-action.js +10 -2
  12. package/dist/worker/feature/ready-plan-projection.js +81 -0
  13. package/dist/worker/feature/reducer.js +2 -1
  14. package/dist/worker/feature/review.js +19 -2
  15. package/dist/worker/feature/run.js +27 -2
  16. package/dist/worker/follow-up/approve.js +5 -2
  17. package/dist/worker/follow-up/factory.js +1 -1
  18. package/dist/worker/observability/read-model.js +246 -41
  19. package/dist/worker/observe/routes.js +158 -15
  20. package/dist/worker/observe/spec-evidence.js +281 -0
  21. package/dist/worker/observe/static/api.js +19 -0
  22. package/dist/worker/observe/static/app.js +2 -2
  23. package/dist/worker/observe/static/relations.js +17 -12
  24. package/dist/worker/observe/static/router.js +8 -0
  25. package/dist/worker/observe/static/styles.css +12 -0
  26. package/dist/worker/observe/static/views/batch.js +3 -2
  27. package/dist/worker/observe/static/views/dag-inspector.js +123 -4
  28. package/dist/worker/observe/static/views/dashboard.js +8 -5
  29. package/dist/worker/observe/static/views/feature.js +43 -4
  30. package/dist/worker/observe/static/views/pool.js +5 -2
  31. package/dist/worker/observe/static/views/run.js +1 -1
  32. package/dist/worker/observe/static/views/task.js +69 -15
  33. package/dist/worker/pool/doctor.js +165 -0
  34. package/dist/worker/pool/migrate-state.js +303 -0
  35. package/dist/worker/pool/run-store.js +205 -17
  36. package/dist/worker/pool/types.js +17 -1
  37. package/dist/worker/pool/validation.js +100 -15
  38. package/dist/worker/report/morning-report.js +12 -2
  39. package/dist/worker/runner/run-ready.js +41 -26
  40. package/dist/worker/task-graph/ready-planner.js +136 -0
  41. package/dist/workflows/dag/convergence/controller.js +16 -8
  42. package/dist/workflows/dag/failure-routing.js +12 -1
  43. package/dist/workflows/dag/init-hybrid.js +837 -8
  44. package/dist/workflows/dag/types.js +1 -0
  45. package/docs/README.md +1 -1
  46. package/docs/agent-dag-recovery-playbook.md +9 -0
  47. package/docs/architecture/evolution.md +4 -3
  48. package/docs/architecture/facts-and-state.md +14 -1
  49. package/docs/architecture/worker-and-feature.md +6 -2
  50. package/docs/decisions/README.md +3 -0
  51. package/docs/design/README.md +8 -0
  52. package/docs/exec-plans/active/README.md +2 -0
  53. package/docs/exec-plans/completed/README.md +3 -2
  54. package/docs/feature-workflow.md +80 -2
  55. package/docs/loop-agent-harness.md +15 -4
  56. package/docs/progress/README.md +4 -0
  57. package/docs/reports/README.md +6 -0
  58. package/docs/templates/backend-test-dag.json +12 -0
  59. package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -0
  60. package/docs/templates/knowledge-sync-dag.json +177 -0
  61. package/docs/templates/knowledge-sync-draft.schema.json +71 -0
  62. package/docs/verification-matrix.md +2 -1
  63. package/package.json +8 -2
  64. package/scripts/kb-bootstrap-init-skeleton.sh +239 -0
  65. package/scripts/kb-graph-incremental-prepare.mjs +372 -0
  66. package/scripts/kb-graph-incremental-prepare.sh +5 -0
  67. package/scripts/kb-graph-materialize.mjs +105 -0
  68. package/scripts/kb-graph-materialize.sh +4 -0
  69. package/scripts/kb-graph-promote.mjs +153 -0
  70. package/scripts/kb-graph-promote.sh +4 -0
  71. package/scripts/kb-query.mjs +554 -0
  72. package/scripts/kb-query.sh +5 -0
  73. package/skills/agent-worker/SKILL.md +3 -1
  74. package/skills/agent-worker/references/agent-worker-operator.md +18 -1
  75. package/skills/frontend-design-review/SKILL.md +26 -24
  76. package/skills/frontend-implementation/SKILL.md +29 -26
  77. package/skills/frontend-implementation/references/node-contracts.md +50 -19
  78. package/skills/frontend-review/SKILL.md +1 -1
  79. package/skills/loop-agent/references/command-reference.md +1 -0
@@ -141,7 +141,9 @@ export async function renderBatch(batchRunId) {
141
141
  const snap = snapshot ?? uiState.lastSnapshot;
142
142
  const rows = tasks.map((task) => {
143
143
  const links = el("div", "artifact-links batch-task-links");
144
- links.appendChild(objectRouteLink("task", task.taskId, snap));
144
+ links.appendChild(
145
+ objectRouteLink("task", task.taskId, snap, task.featureId ?? batch.featureId),
146
+ );
145
147
  links.appendChild(document.createTextNode(" · "));
146
148
  links.appendChild(objectRouteLink("run", task.workerRunId, snap));
147
149
  if (task.dagRunId) {
@@ -223,4 +225,3 @@ export async function renderBatch(batchRunId) {
223
225
  );
224
226
  }
225
227
  }
226
-
@@ -9,6 +9,7 @@ import {
9
9
  UI_TEXT,
10
10
  } from "../constants.js";
11
11
  import { clearNode, el } from "../dom.js";
12
+ import { fetchJson } from "../api.js";
12
13
  import { badge, parseMarkdownBlocks, markdownLinkHref } from "../format.js";
13
14
  import {
14
15
  uiState,
@@ -17,7 +18,10 @@ import {
17
18
  computeFollowLatest,
18
19
  isInteractiveGestureActive,
19
20
  } from "../state.js";
20
- import { renderSessionTimeline } from "./session-timeline.js";
21
+ import {
22
+ formatSessionEventTime,
23
+ renderSessionTimeline,
24
+ } from "./session-timeline.js";
21
25
 
22
26
  export function selectDagNode(dagRunId, nodeId) {
23
27
  const nodeChanged = uiState.selectedDagNodeId !== nodeId;
@@ -268,14 +272,128 @@ export function renderMarkdown(content) {
268
272
  return article;
269
273
  }
270
274
 
275
+ async function renderSpecEvidence(content, dagRunId, nodeId) {
276
+ const evidence = await fetchJson(
277
+ `/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(nodeId)}/spec-evidence`,
278
+ );
279
+ if (!content.isConnected || content.dataset.dagNodeId !== String(nodeId)) return;
280
+ if (!evidence) {
281
+ content.appendChild(el("p", "empty", "无法加载规范证据。"));
282
+ return;
283
+ }
284
+
285
+ const statusLabels = {
286
+ "spec-read": "已读取规范",
287
+ "spec-injected": "仅注入 skill",
288
+ "search-only": "仅执行检索",
289
+ "kb-queried": "已观察知识库查询",
290
+ "no-evidence": "未观察到读取",
291
+ };
292
+ const statusSection = el("div", "spec-evidence-section");
293
+ statusSection.appendChild(el("h4", null, "证据状态"));
294
+ statusSection.appendChild(
295
+ el(
296
+ "span",
297
+ `badge badge-${evidence.status === "spec-read" ? "succeeded" : evidence.status === "no-evidence" ? "failed" : "pending"}`,
298
+ statusLabels[evidence.status] ?? evidence.status,
299
+ ),
300
+ );
301
+ if (evidence.summary) {
302
+ statusSection.appendChild(
303
+ el("p", "spec-evidence-summary", evidence.summary),
304
+ );
305
+ }
306
+ content.appendChild(statusSection);
307
+
308
+ const appendListSection = (title, entries, renderEntry) => {
309
+ if (!entries?.length) return;
310
+ const section = el("div", "spec-evidence-section");
311
+ section.appendChild(el("h4", null, title));
312
+ const list = el("ul", "spec-evidence-list");
313
+ for (const entry of entries) list.appendChild(renderEntry(entry));
314
+ section.appendChild(list);
315
+ content.appendChild(section);
316
+ };
317
+
318
+ appendListSection("注入的 Skill", evidence.skillInjection?.skills, (skill) => {
319
+ const item = el("li", null);
320
+ const icon = el("i", "ri-bookmark-line");
321
+ icon.setAttribute("aria-hidden", "true");
322
+ item.append(icon, document.createTextNode(` ${skill}`));
323
+ return item;
324
+ });
325
+ appendListSection(
326
+ `已读取规范文件(${evidence.specReads?.length ?? 0})`,
327
+ evidence.specReads,
328
+ (read) => {
329
+ const item = el("li", null);
330
+ const icon = el("i", "ri-file-text-line");
331
+ icon.setAttribute("aria-hidden", "true");
332
+ item.append(icon, el("code", null, read.path));
333
+ if (read.timestamp) {
334
+ item.appendChild(
335
+ el(
336
+ "span",
337
+ "spec-evidence-time",
338
+ formatSessionEventTime({ timestamp: read.timestamp }),
339
+ ),
340
+ );
341
+ }
342
+ return item;
343
+ },
344
+ );
345
+ appendListSection(
346
+ `检索操作(${evidence.specSearches?.length ?? 0})`,
347
+ evidence.specSearches,
348
+ (search) => {
349
+ const item = el("li", null);
350
+ const icon = el("i", "ri-search-line");
351
+ icon.setAttribute("aria-hidden", "true");
352
+ item.append(icon, document.createTextNode(` ${search.tool}: ${search.query}`));
353
+ return item;
354
+ },
355
+ );
356
+ appendListSection(
357
+ `知识库查询(${evidence.knowledgeBaseQueries?.length ?? 0})`,
358
+ evidence.knowledgeBaseQueries,
359
+ (query) => {
360
+ const item = el("li", null);
361
+ const icon = el("i", "ri-database-2-line");
362
+ icon.setAttribute("aria-hidden", "true");
363
+ item.append(icon, document.createTextNode(` ${query.connector}`));
364
+ return item;
365
+ },
366
+ );
367
+
368
+ if (evidence.status === "spec-injected" || evidence.status === "no-evidence") {
369
+ const warning = el("div", "spec-evidence-warning");
370
+ const icon = el("i", "ri-alert-line");
371
+ icon.setAttribute("aria-hidden", "true");
372
+ warning.append(
373
+ icon,
374
+ document.createTextNode(
375
+ evidence.status === "no-evidence"
376
+ ? "未观察到规范读取。只有 session event 中成功配对的 read 工具调用才算已读取,模型总结或文件名命中不能作为证明。"
377
+ : "规范 skill 已注入,但未观察到规范读取或知识库查询;grep/find/ls 只能证明检索,不能替代 read。",
378
+ ),
379
+ );
380
+ content.appendChild(warning);
381
+ }
382
+ }
383
+
271
384
  function fillDagInspectorContent(content, dagRunId, node) {
272
- const activeTab =
273
- uiState.dagInspectorTab === "timeline" ? "timeline" : "output";
385
+ const activeTab = ["timeline", "spec-evidence"].includes(
386
+ uiState.dagInspectorTab,
387
+ )
388
+ ? uiState.dagInspectorTab
389
+ : "output";
274
390
  content.id = `dag-inspector-panel-${activeTab}`;
275
391
  content.setAttribute("role", "tabpanel");
276
392
  content.setAttribute("aria-labelledby", `dag-inspector-tab-${activeTab}`);
277
393
  content.dataset.dagNodeId = String(node.nodeId ?? "");
278
- if (activeTab === "timeline") {
394
+ if (activeTab === "spec-evidence") {
395
+ void renderSpecEvidence(content, dagRunId, node.nodeId);
396
+ } else if (activeTab === "timeline") {
279
397
  renderSessionTimeline(content, node.nodeId);
280
398
  } else if (!node.outputPreview && !node.errorPreview) {
281
399
  content.appendChild(el("p", "empty", UI_TEXT.noOutput));
@@ -317,6 +435,7 @@ function buildDagInspectorHeader(dagRunId, node) {
317
435
  const tabDefs = [
318
436
  ["output", "节点输出", "ri-file-text-line"],
319
437
  ["timeline", "执行过程", "ri-route-line"],
438
+ ["spec-evidence", "规范证据", "ri-book-open-line"],
320
439
  ];
321
440
  const tabs = el("div", "dag-inspector-tabs");
322
441
  tabs.setAttribute("role", "tablist");
@@ -112,11 +112,14 @@ export async function renderDashboard(scrollTo) {
112
112
  feature.featureId,
113
113
  snapshot,
114
114
  );
115
- const nextHint =
116
- feature.nextAction?.label ??
117
- (feature.nextAction?.command
118
- ? truncateText(feature.nextAction.command, 48)
119
- : "—");
115
+ // Prefer planner-selected next task; never re-rank ready pool order here.
116
+ const selected = feature.planning?.selected?.[0];
117
+ const nextHint = selected
118
+ ? `Next Task: ${selected.taskId} (${selected.priority})`
119
+ : feature.nextAction?.label ??
120
+ (feature.nextAction?.command
121
+ ? truncateText(feature.nextAction.command, 48)
122
+ : "—");
120
123
  return {
121
124
  onClick: feature.featureId
122
125
  ? () =>
@@ -234,7 +234,9 @@ export async function renderFeatureDetail(featureId) {
234
234
  const meta = el("div", "blocking-meta");
235
235
  if (item.taskId) {
236
236
  meta.appendChild(document.createTextNode("Task "));
237
- meta.appendChild(objectRouteLink("task", item.taskId, snapshot));
237
+ meta.appendChild(
238
+ objectRouteLink("task", item.taskId, snapshot, feature.featureId),
239
+ );
238
240
  }
239
241
  if (item.failureCategory) {
240
242
  meta.appendChild(
@@ -277,6 +279,34 @@ export async function renderFeatureDetail(featureId) {
277
279
 
278
280
  if (actionsEl) {
279
281
  try {
282
+ const planning = feature.planning;
283
+ if (planning) {
284
+ actionsEl.appendChild(el("h3", "section-heading", "Ready 选择计划(只读)"));
285
+ const selected = planning.selected ?? [];
286
+ const deferred = planning.deferred ?? [];
287
+ const blocked = planning.blocked ?? [];
288
+ // Render planner facts in projection order; do not re-sort selected/deferred.
289
+ actionsEl.appendChild(buildTable(
290
+ ["已选择", "延后", "阻塞"],
291
+ [{
292
+ cells: [
293
+ selected.map((task) => `${task.taskId} (${task.priority})`).join(", ") || "无",
294
+ deferred.map((task) => {
295
+ const ahead = Array.isArray(task.selectedAhead) && task.selectedAhead.length
296
+ ? ` ahead=${task.selectedAhead.join("|")}`
297
+ : "";
298
+ return `${task.taskId}: ${task.reasonCode || "selection-limit"}${ahead}`;
299
+ }).join(", ") || "无",
300
+ blocked.map((task) => {
301
+ const by = Array.isArray(task.blockedBy) && task.blockedBy.length
302
+ ? ` by ${task.blockedBy.join(",")}`
303
+ : "";
304
+ return `${task.taskId}: ${task.reasonCode || task.reason || "blocked"}${by}`;
305
+ }).join(", ") || "无",
306
+ ],
307
+ }],
308
+ ));
309
+ }
280
310
  actionsEl.appendChild(
281
311
  el("h3", "section-heading", "建议命令(derived/advisory · 仅可复制)"),
282
312
  );
@@ -339,10 +369,14 @@ export async function renderFeatureDetail(featureId) {
339
369
  ["Task", "类型", "状态", "pool 关联"],
340
370
  tasks.map((t) => {
341
371
  const pool = (snapshot?.tasks ?? []).find(
342
- (p) => p.taskId === t.taskId,
372
+ (p) =>
373
+ p.taskId === t.taskId &&
374
+ (!feature.featureId || p.featureId === feature.featureId),
343
375
  );
344
376
  const links = el("div", "artifact-links");
345
- links.appendChild(objectRouteLink("task", t.taskId, snapshot));
377
+ links.appendChild(
378
+ objectRouteLink("task", t.taskId, snapshot, feature.featureId),
379
+ );
346
380
  if (pool?.workerRunId) {
347
381
  links.appendChild(document.createTextNode(" · "));
348
382
  links.appendChild(
@@ -381,7 +415,12 @@ export async function renderFeatureDetail(featureId) {
381
415
  cells: [
382
416
  f.followUpId,
383
417
  f.proposedTaskId ?? "—",
384
- objectRouteLink("task", f.parentTaskId, snapshot),
418
+ objectRouteLink(
419
+ "task",
420
+ f.parentTaskId,
421
+ snapshot,
422
+ feature.featureId,
423
+ ),
385
424
  artifactLink("draft", f.draftPath),
386
425
  ],
387
426
  })),
@@ -285,11 +285,14 @@ export async function renderPool() {
285
285
  if (tasks.length > 0) {
286
286
  const rows = tasks.map((task) => {
287
287
  const taskLink = document.createElement("a");
288
- taskLink.href = `#/task/${encodeURIComponent(task.taskId)}`;
288
+ const taskPath = task.featureId
289
+ ? `/feature/${encodeURIComponent(task.featureId)}/task/${encodeURIComponent(task.taskId)}`
290
+ : `/task/${encodeURIComponent(task.taskId)}`;
291
+ taskLink.href = `#${taskPath}`;
289
292
  taskLink.textContent = task.taskId;
290
293
  taskLink.addEventListener("click", (e) => {
291
294
  e.preventDefault();
292
- navigate(`#/task/${encodeURIComponent(task.taskId)}`);
295
+ navigate(taskPath);
293
296
  });
294
297
 
295
298
  let runCell = "—";
@@ -229,7 +229,7 @@ export function renderRunPanels(task, events, snapshot) {
229
229
  metaEl.appendChild(
230
230
  metaGrid([
231
231
  ["Worker 运行 ID", task.workerRunId ?? "—"],
232
- ["Task", objectRouteLink("task", task.taskId, snapshot)],
232
+ ["Task", objectRouteLink("task", task.taskId, snapshot, task.featureId)],
233
233
  [
234
234
  "Feature",
235
235
  task.featureId
@@ -36,7 +36,12 @@ import {
36
36
  markdownLinkHref,
37
37
  } from "../format.js";
38
38
  import { parseRoute, navigate, buildHashPath } from "../router.js";
39
- import { fetchJson, artifactUrl, parseArtifactPreviewResponse } from "../api.js";
39
+ import {
40
+ fetchJson,
41
+ fetchJsonResult,
42
+ artifactUrl,
43
+ parseArtifactPreviewResponse,
44
+ } from "../api.js";
40
45
  import {
41
46
  objectRouteLink,
42
47
  findFeatureInSnapshot,
@@ -68,12 +73,20 @@ import { showView, setBreadcrumb, updateHeaderRefresh, mountRelationBar } from "
68
73
  import { artifactLink } from "../kpi.js";
69
74
  import { findBatchRunIdForTask, taskRunHistoryRow } from "../run-processing.js";
70
75
 
71
- export async function renderTaskDetail(taskId) {
76
+ export async function renderTaskDetail(featureId, taskId) {
77
+ if (taskId === undefined) {
78
+ taskId = featureId;
79
+ featureId = undefined;
80
+ }
81
+ const taskApi = featureId
82
+ ? `/api/features/${encodeURIComponent(featureId)}/tasks/${encodeURIComponent(taskId)}`
83
+ : `/api/tasks/${encodeURIComponent(taskId)}`;
84
+ const displayLabel = featureId ? `${featureId}::${taskId}` : taskId;
72
85
  showView("task");
73
86
  setBreadcrumb([
74
87
  { label: UI_TEXT.dashboard, href: "#/" },
75
88
  { label: "资源池", href: "#/pool" },
76
- { label: taskId },
89
+ { label: displayLabel },
77
90
  ]);
78
91
 
79
92
  const titleEl = document.getElementById("task-title");
@@ -82,14 +95,16 @@ export async function renderTaskDetail(taskId) {
82
95
  const evidenceEl = document.getElementById("task-evidence");
83
96
  const runsEl = document.getElementById("task-runs");
84
97
  if (!metaEl || !linksEl || !evidenceEl || !runsEl) return false;
85
- if (titleEl) titleEl.textContent = taskId;
98
+ if (titleEl) titleEl.textContent = displayLabel;
86
99
  mountViewState(metaEl, "loading", "加载 Task…");
87
100
 
88
- const [task, history, snapshot] = await Promise.all([
89
- fetchJson(`/api/tasks/${encodeURIComponent(taskId)}`),
90
- fetchJson(`/api/tasks/${encodeURIComponent(taskId)}/runs?limit=20`),
101
+ const [taskResult, historyResult, snapshot] = await Promise.all([
102
+ fetchJsonResult(taskApi),
103
+ fetchJsonResult(`${taskApi}/runs?limit=20`),
91
104
  fetchJson("/api/snapshot"),
92
105
  ]);
106
+ const task = taskResult.ok ? taskResult.body : null;
107
+ const history = historyResult.ok ? historyResult.body : null;
93
108
  if (snapshot) {
94
109
  uiState.lastSnapshot = snapshot;
95
110
  updateHeaderRefresh(snapshot.generatedAt);
@@ -101,7 +116,34 @@ export async function renderTaskDetail(taskId) {
101
116
  clearNode(runsEl);
102
117
 
103
118
  if (!task) {
104
- mountViewState(metaEl, "error", `未找到 Task:${taskId}`);
119
+ if (taskResult.status === 409) {
120
+ const candidates = Array.isArray(taskResult.body?.candidates)
121
+ ? taskResult.body.candidates
122
+ : [];
123
+ mountViewState(
124
+ metaEl,
125
+ "error",
126
+ `Task 身份歧义:${taskId} 同时属于多个 Feature,请选择 Feature 作用域入口。`,
127
+ );
128
+ if (candidates.length > 0) {
129
+ const list = el("div", "artifact-links");
130
+ for (const c of candidates) {
131
+ if (!c?.featureId || !c?.taskId) continue;
132
+ const path = `/feature/${encodeURIComponent(c.featureId)}/task/${encodeURIComponent(c.taskId)}`;
133
+ const a = el("a", "object-link", `${c.featureId}::${c.taskId}`);
134
+ a.href = `#${path}`;
135
+ a.addEventListener("click", (e) => {
136
+ e.preventDefault();
137
+ navigate(path);
138
+ });
139
+ list.appendChild(a);
140
+ list.appendChild(document.createTextNode(" "));
141
+ }
142
+ metaEl.appendChild(list);
143
+ }
144
+ } else {
145
+ mountViewState(metaEl, "error", `未找到 Task:${taskId}`);
146
+ }
105
147
  mountRelationBar("task-relations", {}, snapshot);
106
148
  return false;
107
149
  }
@@ -200,7 +242,7 @@ export async function renderTaskDetail(taskId) {
200
242
  const more = el("button", "link-button", "加载更早的运行");
201
243
  more.type = "button";
202
244
  more.addEventListener("click", () => {
203
- void loadOlderTaskRuns(taskId, history.nextBefore, runsEl);
245
+ void loadOlderTaskRuns(featureId, taskId, history.nextBefore, runsEl);
204
246
  });
205
247
  runsEl.appendChild(more);
206
248
  }
@@ -212,9 +254,18 @@ export async function renderTaskDetail(taskId) {
212
254
  return active;
213
255
  }
214
256
 
215
- export async function loadOlderTaskRuns(taskId, before, runsEl) {
257
+ export async function loadOlderTaskRuns(featureId, taskId, before, runsEl) {
258
+ if (runsEl === undefined) {
259
+ runsEl = before;
260
+ before = taskId;
261
+ taskId = featureId;
262
+ featureId = undefined;
263
+ }
264
+ const taskApi = featureId
265
+ ? `/api/features/${encodeURIComponent(featureId)}/tasks/${encodeURIComponent(taskId)}`
266
+ : `/api/tasks/${encodeURIComponent(taskId)}`;
216
267
  const page = await fetchJson(
217
- `/api/tasks/${encodeURIComponent(taskId)}/runs?limit=20&before=${encodeURIComponent(before)}`,
268
+ `${taskApi}/runs?limit=20&before=${encodeURIComponent(before)}`,
218
269
  );
219
270
  if (!page?.runs?.length) return;
220
271
  const table = runsEl.querySelector("table");
@@ -234,7 +285,7 @@ export async function loadOlderTaskRuns(taskId, before, runsEl) {
234
285
  if (btn) {
235
286
  if (page.nextBefore) {
236
287
  btn.onclick = () => {
237
- void loadOlderTaskRuns(taskId, page.nextBefore, runsEl);
288
+ void loadOlderTaskRuns(featureId, taskId, page.nextBefore, runsEl);
238
289
  };
239
290
  } else {
240
291
  btn.remove();
@@ -242,11 +293,15 @@ export async function loadOlderTaskRuns(taskId, before, runsEl) {
242
293
  }
243
294
  }
244
295
 
245
- export function startTaskPolling(taskId) {
296
+ export function startTaskPolling(featureId, taskId) {
297
+ if (taskId === undefined) {
298
+ taskId = featureId;
299
+ featureId = undefined;
300
+ }
246
301
  if (uiState.taskPollTimer) clearTimeout(uiState.taskPollTimer);
247
302
  const generation = ++uiState.pollingGeneration;
248
303
  const poll = async () => {
249
- const active = await renderTaskDetail(taskId);
304
+ const active = await renderTaskDetail(featureId, taskId);
250
305
  if (generation !== uiState.pollingGeneration || !active) return;
251
306
  const delay = detailPollDelay("running", isPageVisible());
252
307
  if (delay == null) return;
@@ -257,4 +312,3 @@ export function startTaskPolling(taskId) {
257
312
  };
258
313
  void poll();
259
314
  }
260
-
@@ -0,0 +1,165 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { findRunByWorkerRunId, getEventsJsonlPath, listLegacyStateFiles, listTaskPoolStates, pathExists, readJsonlFile, sha256File, } from "./run-store.js";
4
+ import { TASK_POOL_STATE_ERROR_CODES, } from "./types.js";
5
+ /**
6
+ * Read-only inventory of legacy flat states and feature-scoped v2 states.
7
+ */
8
+ export async function diagnoseTaskPoolStates(repoRoot, options = {}) {
9
+ const findings = [];
10
+ const legacyFiles = await listLegacyStateFiles(repoRoot);
11
+ const legacy = [];
12
+ for (const file of legacyFiles) {
13
+ const sha256 = await sha256File(file.path);
14
+ let state;
15
+ try {
16
+ state = JSON.parse(await readFile(file.path, "utf-8"));
17
+ }
18
+ catch {
19
+ legacy.push({
20
+ taskId: file.taskId,
21
+ legacyPath: file.path,
22
+ sha256,
23
+ resolution: "unresolved",
24
+ code: TASK_POOL_STATE_ERROR_CODES.MIGRATION_REQUIRED,
25
+ detail: "legacy state file is corrupt",
26
+ });
27
+ findings.push({
28
+ code: TASK_POOL_STATE_ERROR_CODES.MIGRATION_REQUIRED,
29
+ message: `corrupt legacy state: ${file.taskId}`,
30
+ });
31
+ continue;
32
+ }
33
+ const resolved = await resolveLegacyIdentity(repoRoot, state, options.mapping);
34
+ legacy.push({
35
+ taskId: file.taskId,
36
+ legacyPath: file.path,
37
+ sha256,
38
+ status: state.status,
39
+ ...resolved,
40
+ });
41
+ if (resolved.resolution === "unresolved" || resolved.resolution === "ambiguous") {
42
+ findings.push({
43
+ code: resolved.code ?? TASK_POOL_STATE_ERROR_CODES.MIGRATION_REQUIRED,
44
+ message: resolved.detail ??
45
+ `legacy state requires migration: ${file.taskId}`,
46
+ });
47
+ }
48
+ else {
49
+ findings.push({
50
+ code: TASK_POOL_STATE_ERROR_CODES.MIGRATION_REQUIRED,
51
+ message: `legacy state ${file.taskId} maps to ${resolved.featureId}/${file.taskId} via ${resolved.resolution}`,
52
+ });
53
+ }
54
+ }
55
+ const v2States = await listTaskPoolStates(repoRoot);
56
+ const v2 = v2States.map((state) => ({
57
+ featureId: state.featureId,
58
+ taskId: state.taskId,
59
+ status: state.status,
60
+ }));
61
+ return {
62
+ schemaVersion: 1,
63
+ repoRoot,
64
+ legacyCount: legacy.length,
65
+ v2Count: v2.length,
66
+ legacy,
67
+ v2,
68
+ findings,
69
+ };
70
+ }
71
+ export async function resolveLegacyIdentity(repoRoot, state, mapping) {
72
+ const candidates = [];
73
+ if (state.workerRunId) {
74
+ const run = await findRunByWorkerRunId(repoRoot, state.workerRunId);
75
+ if (run && run.taskId === state.taskId) {
76
+ return {
77
+ resolution: "workerRunId",
78
+ featureId: run.featureId,
79
+ };
80
+ }
81
+ }
82
+ if (state.retryOfWorkerRunId) {
83
+ const run = await findRunByWorkerRunId(repoRoot, state.retryOfWorkerRunId);
84
+ if (run && run.taskId === state.taskId) {
85
+ return {
86
+ resolution: "retryOfWorkerRunId",
87
+ featureId: run.featureId,
88
+ };
89
+ }
90
+ }
91
+ const events = await readJsonlFile(getEventsJsonlPath(repoRoot));
92
+ const followUpHits = events.filter((event) => event.type === "follow-up-approved" &&
93
+ event.proposedTaskId === state.taskId);
94
+ const uniqueFeatureIds = [...new Set(followUpHits.map((event) => event.featureId))];
95
+ if (uniqueFeatureIds.length === 1) {
96
+ return {
97
+ resolution: "follow-up-approved",
98
+ featureId: uniqueFeatureIds[0],
99
+ };
100
+ }
101
+ if (uniqueFeatureIds.length > 1) {
102
+ for (const featureId of uniqueFeatureIds) {
103
+ candidates.push({ featureId, taskId: state.taskId });
104
+ }
105
+ return {
106
+ resolution: "ambiguous",
107
+ candidates,
108
+ code: TASK_POOL_STATE_ERROR_CODES.IDENTITY_AMBIGUOUS,
109
+ detail: `multiple follow-up-approved features for ${state.taskId}`,
110
+ };
111
+ }
112
+ const mapped = mapping?.[state.taskId];
113
+ if (mapped) {
114
+ return {
115
+ resolution: "operator-mapping",
116
+ featureId: mapped,
117
+ };
118
+ }
119
+ return {
120
+ resolution: "unresolved",
121
+ code: TASK_POOL_STATE_ERROR_CODES.MIGRATION_REQUIRED,
122
+ detail: `no unique identity evidence for legacy state ${state.taskId}`,
123
+ };
124
+ }
125
+ export async function loadOperatorMapping(mappingPath) {
126
+ if (!mappingPath)
127
+ return undefined;
128
+ if (!(await pathExists(mappingPath))) {
129
+ throw new Error(`mapping file not found: ${mappingPath}`);
130
+ }
131
+ const raw = JSON.parse(await readFile(mappingPath, "utf-8"));
132
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
133
+ throw new Error("mapping file must be a JSON object of taskId -> featureId");
134
+ }
135
+ const mapping = {};
136
+ for (const [taskId, featureId] of Object.entries(raw)) {
137
+ if (typeof featureId !== "string" || featureId.length === 0) {
138
+ throw new Error(`mapping for ${taskId} must be a non-empty featureId string`);
139
+ }
140
+ mapping[taskId] = featureId;
141
+ }
142
+ return mapping;
143
+ }
144
+ export function formatDoctorHuman(report) {
145
+ const lines = [
146
+ `Task Pool doctor`,
147
+ `repo: ${report.repoRoot}`,
148
+ `legacy states: ${report.legacyCount}`,
149
+ `v2 states: ${report.v2Count}`,
150
+ ];
151
+ for (const item of report.legacy) {
152
+ const target = item.featureId != null ? `${item.featureId}/${item.taskId}` : "(unresolved)";
153
+ lines.push(`- legacy ${item.taskId}: ${item.resolution} -> ${target}${item.detail ? ` (${item.detail})` : ""}`);
154
+ }
155
+ for (const finding of report.findings) {
156
+ lines.push(`[${finding.code}] ${finding.message}`);
157
+ }
158
+ if (report.findings.length === 0) {
159
+ lines.push("No migration-required findings.");
160
+ }
161
+ return `${lines.join("\n")}\n`;
162
+ }
163
+ export function relativeLegacyName(legacyPath) {
164
+ return path.basename(legacyPath);
165
+ }