@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.
- package/CHANGELOG.md +50 -2
- package/README.md +30 -2
- package/dist/application/dag/generate-task-dag.js +30 -0
- package/dist/cli/command-definitions.js +10 -3
- package/dist/commands/knowledge.js +129 -31
- package/dist/governance/manifest-types.js +3 -0
- package/dist/task/config-types.js +5 -1
- package/dist/worker/cli.js +96 -1
- package/dist/worker/delivery/package.js +3 -3
- package/dist/worker/feature/decision-loader.js +37 -6
- package/dist/worker/feature/next-action.js +10 -2
- package/dist/worker/feature/ready-plan-projection.js +81 -0
- package/dist/worker/feature/reducer.js +2 -1
- package/dist/worker/feature/review.js +19 -2
- package/dist/worker/feature/run.js +27 -2
- package/dist/worker/follow-up/approve.js +5 -2
- package/dist/worker/follow-up/factory.js +1 -1
- package/dist/worker/observability/read-model.js +246 -41
- package/dist/worker/observe/routes.js +158 -15
- package/dist/worker/observe/spec-evidence.js +281 -0
- package/dist/worker/observe/static/api.js +19 -0
- package/dist/worker/observe/static/app.js +2 -2
- package/dist/worker/observe/static/relations.js +17 -12
- package/dist/worker/observe/static/router.js +8 -0
- package/dist/worker/observe/static/styles.css +12 -0
- package/dist/worker/observe/static/views/batch.js +3 -2
- package/dist/worker/observe/static/views/dag-inspector.js +123 -4
- package/dist/worker/observe/static/views/dashboard.js +8 -5
- package/dist/worker/observe/static/views/feature.js +43 -4
- package/dist/worker/observe/static/views/pool.js +5 -2
- package/dist/worker/observe/static/views/run.js +1 -1
- package/dist/worker/observe/static/views/task.js +69 -15
- package/dist/worker/pool/doctor.js +165 -0
- package/dist/worker/pool/migrate-state.js +303 -0
- package/dist/worker/pool/run-store.js +205 -17
- package/dist/worker/pool/types.js +17 -1
- package/dist/worker/pool/validation.js +100 -15
- package/dist/worker/report/morning-report.js +12 -2
- package/dist/worker/runner/run-ready.js +41 -26
- package/dist/worker/task-graph/ready-planner.js +136 -0
- package/dist/workflows/dag/convergence/controller.js +16 -8
- package/dist/workflows/dag/failure-routing.js +12 -1
- package/dist/workflows/dag/init-hybrid.js +837 -8
- package/dist/workflows/dag/types.js +1 -0
- package/docs/README.md +1 -1
- package/docs/agent-dag-recovery-playbook.md +9 -0
- package/docs/architecture/evolution.md +4 -3
- package/docs/architecture/facts-and-state.md +14 -1
- package/docs/architecture/worker-and-feature.md +6 -2
- package/docs/decisions/README.md +3 -0
- package/docs/design/README.md +8 -0
- package/docs/exec-plans/active/README.md +2 -0
- package/docs/exec-plans/completed/README.md +3 -2
- package/docs/feature-workflow.md +80 -2
- package/docs/loop-agent-harness.md +15 -4
- package/docs/progress/README.md +4 -0
- package/docs/reports/README.md +6 -0
- package/docs/templates/backend-test-dag.json +12 -0
- package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -0
- package/docs/templates/knowledge-sync-dag.json +177 -0
- package/docs/templates/knowledge-sync-draft.schema.json +71 -0
- package/docs/verification-matrix.md +2 -1
- package/package.json +8 -2
- package/scripts/kb-bootstrap-init-skeleton.sh +239 -0
- package/scripts/kb-graph-incremental-prepare.mjs +372 -0
- package/scripts/kb-graph-incremental-prepare.sh +5 -0
- package/scripts/kb-graph-materialize.mjs +105 -0
- package/scripts/kb-graph-materialize.sh +4 -0
- package/scripts/kb-graph-promote.mjs +153 -0
- package/scripts/kb-graph-promote.sh +4 -0
- package/scripts/kb-query.mjs +554 -0
- package/scripts/kb-query.sh +5 -0
- package/skills/agent-worker/SKILL.md +3 -1
- package/skills/agent-worker/references/agent-worker-operator.md +18 -1
- package/skills/frontend-design-review/SKILL.md +26 -24
- package/skills/frontend-implementation/SKILL.md +29 -26
- package/skills/frontend-implementation/references/node-contracts.md +50 -19
- package/skills/frontend-review/SKILL.md +1 -1
- 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(
|
|
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 {
|
|
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
|
|
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 === "
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
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) =>
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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:
|
|
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 =
|
|
98
|
+
if (titleEl) titleEl.textContent = displayLabel;
|
|
86
99
|
mountViewState(metaEl, "loading", "加载 Task…");
|
|
87
100
|
|
|
88
|
-
const [
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|