@tea-agent/loop-agent 0.10.0-alpha.0 → 0.11.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 (48) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +39 -47
  3. package/README.md +33 -6
  4. package/dist/application/dag/args.js +2 -3
  5. package/dist/application/dag/generate-task-dag.js +5 -14
  6. package/dist/cli/command-definitions.js +44 -5
  7. package/dist/cli/program.js +37 -3
  8. package/dist/cli/update/notifier.js +117 -0
  9. package/dist/cli/update/npm-client.js +151 -0
  10. package/dist/cli/update/policy.js +58 -0
  11. package/dist/cli/update/state.js +68 -0
  12. package/dist/cli.js +33 -0
  13. package/dist/commands/init.js +432 -58
  14. package/dist/commands/plan.js +50 -0
  15. package/dist/governance/exec-plans.js +545 -0
  16. package/dist/governance/manifest-types.js +0 -5
  17. package/dist/task/config-types.js +0 -1
  18. package/dist/worker/observe/static/app.js +326 -45
  19. package/dist/worker/observe/static/styles.css +1 -0
  20. package/dist/workflows/dag/governance-profile.js +0 -10
  21. package/dist/workflows/dag/init-hybrid.js +5 -201
  22. package/dist/workflows/dag/sdd-embedded.js +128 -0
  23. package/dist/workflows/dag/skill-instructions.js +5 -4
  24. package/docs/README.md +1 -0
  25. package/docs/agent-dag-runner.md +2 -2
  26. package/docs/architecture/runtime-boundaries.md +3 -0
  27. package/docs/design/README.md +1 -0
  28. package/docs/development-principles.md +1 -1
  29. package/docs/exec-plans/active/README.md +2 -2
  30. package/docs/exec-plans/completed/README.md +6 -0
  31. package/docs/feature-workflow.md +27 -21
  32. package/docs/harness-methodology-debugging.md +1 -1
  33. package/docs/harness-methodology-tdd.md +3 -3
  34. package/docs/init-surface.manifest.json +23 -50
  35. package/docs/loop-agent-harness.md +8 -3
  36. package/docs/progress/README.md +6 -0
  37. package/docs/reports/README.md +10 -0
  38. package/docs/templates/project-start-checklist.md +2 -2
  39. package/harness.json +1 -3
  40. package/package.json +3 -3
  41. package/skills/frontend-implementation/SKILL.md +3 -0
  42. package/skills/loop-agent/references/command-reference.md +6 -0
  43. package/skills/loop-agent/references/docs-converge.md +5 -5
  44. package/skills/loop-agent/references/task-workflow.md +1 -1
  45. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +0 -131
  46. package/docs/templates/backend-test-dag.json +0 -213
  47. package/docs/templates/backend-test-dag.retrospect.prompt.md +0 -128
  48. package/docs/templates/backend-test-dag.review-cases.prompt.md +0 -85
@@ -9,6 +9,7 @@ const DAG_INSPECTOR_MAX_WIDTH = 680;
9
9
  const DAG_INSPECTOR_RIGHT_OFFSET = 24;
10
10
  const DAG_INSPECTOR_VIEWPORT_MARGIN = 48;
11
11
  const DAG_INSPECTOR_KEYBOARD_STEP = 24;
12
+ const OUTPUT_FOLLOW_LATEST_PX = 48;
12
13
 
13
14
  const UI_TEXT = {
14
15
  dashboard: "总览",
@@ -163,6 +164,9 @@ let dagInspectorWidth = null;
163
164
  let dagGraphViewportState = null;
164
165
  const dagGraphViewportStates = new Map();
165
166
  let dagTimelineViewportState = null;
167
+ let dagNodeOutputViewportState = null;
168
+ let runOutputViewportState = null;
169
+ let dagInspectorContentSignature = null;
166
170
  let pollingGeneration = 0;
167
171
 
168
172
  function isPageVisible() {
@@ -325,6 +329,66 @@ export function updateDagTimelineViewportState(
325
329
  };
326
330
  }
327
331
 
332
+ // Unified follow-latest helper for DAG node output and Worker output scroll
333
+ // regions. Returns true only when the user is parked within `threshold` px of
334
+ // the bottom, mirroring the existing process-timeline behaviour.
335
+ export function computeFollowLatest(
336
+ scrollTop,
337
+ scrollHeight,
338
+ clientHeight,
339
+ threshold = OUTPUT_FOLLOW_LATEST_PX,
340
+ ) {
341
+ const top = Number(scrollTop);
342
+ const height = Number(scrollHeight);
343
+ const client = Number(clientHeight);
344
+ if (!Number.isFinite(top) || !Number.isFinite(height) || !Number.isFinite(client)) {
345
+ return false;
346
+ }
347
+ if (height <= 0 || client <= 0) return false;
348
+ return height - top - client < Math.max(0, Number(threshold) || 0);
349
+ }
350
+
351
+ // Generalised viewport-state record keyed by `regionKey`, so the same helper
352
+ // serves the DAG node inspector output ("dag:<runId>:<nodeId>:output") and the
353
+ // Worker run output ("run:<runId>:output").
354
+ export function updateDagOutputViewportState(
355
+ _previous,
356
+ regionKey,
357
+ scrollLeft,
358
+ scrollTop,
359
+ followLatest,
360
+ ) {
361
+ return {
362
+ regionKey,
363
+ scrollLeft: Math.max(0, Number(scrollLeft) || 0),
364
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
365
+ followLatest: Boolean(followLatest),
366
+ };
367
+ }
368
+
369
+ // Poll-render decision helpers. Initial paints always rebuild the shell; a
370
+ // non-initial refresh reuses the existing viewport/inspector DOM identity so
371
+ // active pointer-capture targets (scrollbar / resize handle) survive.
372
+ export function shouldReuseDagDetailShell(initial, hasExistingViewport) {
373
+ return !Boolean(initial) && Boolean(hasExistingViewport);
374
+ }
375
+
376
+ export function shouldPreserveDiagnosticsOpen(initial, existingOpen) {
377
+ if (initial) return false;
378
+ return Boolean(existingOpen);
379
+ }
380
+
381
+ // Detects an in-progress resize/scroll gesture so the poll renderer can defer
382
+ // tearing down the interactive shell until the gesture releases.
383
+ export function isInteractiveGestureActive(bodyClassList) {
384
+ const list = bodyClassList;
385
+ if (!list || typeof list.contains !== "function") return false;
386
+ return (
387
+ list.contains("is-resizing-dag-inspector") ||
388
+ list.contains("is-resizing-dag-graph")
389
+ );
390
+ }
391
+
328
392
  export function parseHashRoute(hashValue) {
329
393
  const hash = hashValue.replace(/^#/, "") || "/";
330
394
  const batchMatch = /^\/batch\/([^/]+)$/.exec(hash);
@@ -882,8 +946,11 @@ function createDagInspectorResizeHandle(panel) {
882
946
  function captureDagTimelineViewportState(panel) {
883
947
  const scrollEl = panel.querySelector(".process-timeline-scroll");
884
948
  if (scrollEl?.dataset.dagRunId && scrollEl.dataset.nodeId) {
885
- const followLatest =
886
- scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 48;
949
+ const followLatest = computeFollowLatest(
950
+ scrollEl.scrollTop,
951
+ scrollEl.scrollHeight,
952
+ scrollEl.clientHeight,
953
+ );
887
954
  dagTimelineViewportState = updateDagTimelineViewportState(
888
955
  dagTimelineViewportState,
889
956
  scrollEl.dataset.dagRunId,
@@ -943,8 +1010,11 @@ function renderSessionTimeline(content, nodeId) {
943
1010
  content.addEventListener(
944
1011
  "scroll",
945
1012
  () => {
946
- const followLatest =
947
- content.scrollHeight - content.scrollTop - content.clientHeight < 48;
1013
+ const followLatest = computeFollowLatest(
1014
+ content.scrollTop,
1015
+ content.scrollHeight,
1016
+ content.clientHeight,
1017
+ );
948
1018
  dagTimelineViewportState = updateDagTimelineViewportState(
949
1019
  dagTimelineViewportState,
950
1020
  currentDagRunId,
@@ -1197,19 +1267,42 @@ function svgEl(tag, attrs = {}) {
1197
1267
  return node;
1198
1268
  }
1199
1269
 
1200
- function renderDagGraph(dag, dagRunId) {
1270
+ function renderDagGraph(dag, dagRunId, existingWrap) {
1201
1271
  const nodes = dag.nodes ?? [];
1202
1272
  const edges = dag.edges ?? [];
1203
1273
  const layout = layoutDag(nodes, edges, dag.ranks ?? []);
1204
- const wrap = el("div", "dag-graph-wrap");
1274
+ const existingViewport = existingWrap?.querySelector?.(":scope > .dag-graph-viewport") ?? null;
1275
+ const reuseViewport =
1276
+ Boolean(existingWrap?.isConnected) &&
1277
+ Boolean(existingViewport?.isConnected) &&
1278
+ existingViewport.classList?.contains("dag-graph-viewport") &&
1279
+ existingViewport.dataset?.dagRunId === dagRunId;
1280
+ // reuse-dag-graph-wrap: the live viewport remains attached to the document
1281
+ // during polling. Only its toolbar and SVG contents are patched in place.
1282
+ const wrap = reuseViewport ? existingWrap : el("div", "dag-graph-wrap");
1205
1283
  const toolbar = el("div", "dag-graph-toolbar");
1206
1284
  toolbar.appendChild(el("span", "dag-graph-summary", `${nodes.length} 节点 · ${layout.edges.length} 条依赖`));
1207
1285
  const reset = el("button", "dag-graph-reset", "重置视图");
1208
1286
  toolbar.appendChild(reset);
1209
- wrap.appendChild(toolbar);
1210
- if (edges.length === 0) wrap.appendChild(el("p", "dag-graph-fallback", "该运行没有可用依赖数据;图按并行层级展示,节点表格仍保留完整数据。"));
1211
- const viewport = el("div", "dag-graph-viewport");
1287
+ const previousToolbar = reuseViewport
1288
+ ? wrap.querySelector(":scope > .dag-graph-toolbar")
1289
+ : null;
1290
+ if (previousToolbar) wrap.replaceChild(toolbar, previousToolbar);
1291
+ else wrap.prepend(toolbar);
1292
+ const previousFallback = reuseViewport
1293
+ ? wrap.querySelector(":scope > .dag-graph-fallback")
1294
+ : null;
1295
+ if (edges.length === 0) {
1296
+ const fallback = previousFallback ?? el("p", "dag-graph-fallback");
1297
+ fallback.textContent = "该运行没有可用依赖数据;图按并行层级展示,节点表格仍保留完整数据。";
1298
+ if (!previousFallback) wrap.insertBefore(fallback, existingViewport ?? null);
1299
+ } else {
1300
+ previousFallback?.remove();
1301
+ }
1302
+ const viewport =
1303
+ reuseViewport && existingViewport ? existingViewport : el("div", "dag-graph-viewport");
1212
1304
  viewport.dataset.dagRunId = dagRunId;
1305
+ clearNode(viewport);
1213
1306
  const svg = svgEl("svg", { class: "dag-graph", viewBox: `0 0 ${layout.width} ${layout.height}`, width: layout.width, height: layout.height, role: "img", "aria-label": `DAG 依赖图,共 ${nodes.length} 个节点` });
1214
1307
  const defs = svgEl("defs");
1215
1308
  const marker = svgEl("marker", { id: "dag-arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse" });
@@ -1236,26 +1329,39 @@ function renderDagGraph(dag, dagRunId) {
1236
1329
  group.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); } });
1237
1330
  svg.appendChild(group);
1238
1331
  }
1239
- viewport.appendChild(svg); wrap.appendChild(viewport);
1332
+ viewport.appendChild(svg);
1333
+ if (!reuseViewport) wrap.appendChild(viewport);
1240
1334
  const graphViewportState =
1241
1335
  dagGraphViewportStates.get(dagRunId) ??
1242
1336
  (dagGraphViewportState?.dagRunId === dagRunId
1243
1337
  ? dagGraphViewportState
1244
1338
  : null);
1245
- viewport.addEventListener(
1246
- "scroll",
1247
- () => {
1248
- dagGraphViewportState = updateDagGraphViewportState(
1249
- dagGraphViewportState,
1250
- dagRunId,
1251
- viewport.scrollLeft,
1252
- viewport.scrollTop,
1253
- );
1254
- dagGraphViewportStates.set(dagRunId, dagGraphViewportState);
1255
- },
1256
- { passive: true },
1257
- );
1258
- if (graphViewportState) {
1339
+ if (!reuseViewport) {
1340
+ viewport.addEventListener(
1341
+ "scroll",
1342
+ () => {
1343
+ dagGraphViewportState = updateDagGraphViewportState(
1344
+ dagGraphViewportState,
1345
+ dagRunId,
1346
+ viewport.scrollLeft,
1347
+ viewport.scrollTop,
1348
+ );
1349
+ dagGraphViewportStates.set(dagRunId, dagGraphViewportState);
1350
+ },
1351
+ { passive: true },
1352
+ );
1353
+ viewport.addEventListener("pointerdown", () => {
1354
+ document.body.classList.add("is-resizing-dag-graph");
1355
+ const finish = () => {
1356
+ document.body.classList.remove("is-resizing-dag-graph");
1357
+ window.removeEventListener("pointerup", finish, true);
1358
+ window.removeEventListener("pointercancel", finish, true);
1359
+ };
1360
+ window.addEventListener("pointerup", finish, true);
1361
+ window.addEventListener("pointercancel", finish, true);
1362
+ }, { passive: true });
1363
+ }
1364
+ if (!reuseViewport && graphViewportState) {
1259
1365
  const restoreViewport = () => {
1260
1366
  viewport.scrollLeft = graphViewportState.scrollLeft;
1261
1367
  viewport.scrollTop = graphViewportState.scrollTop;
@@ -1389,7 +1495,7 @@ function renderMarkdown(content) {
1389
1495
  return article;
1390
1496
  }
1391
1497
 
1392
- function renderDagInspector(dagRunId, node) {
1498
+ function renderDagInspector(dagRunId, node, initial = true) {
1393
1499
  const panel = document.getElementById("dag-inspector");
1394
1500
  if (!panel) return;
1395
1501
  captureDagTimelineViewportState(panel);
@@ -1399,17 +1505,97 @@ function renderDagInspector(dagRunId, node) {
1399
1505
  return;
1400
1506
  }
1401
1507
 
1508
+ // Defer tearing down the inspector shell while the user is actively
1509
+ // dragging the resize handle (or the graph) so the pointer-capture target
1510
+ // is not destroyed mid-gesture; the next poll refreshes the content.
1511
+ if (isInteractiveGestureActive(document.body.classList)) {
1512
+ return;
1513
+ }
1514
+
1515
+ const existingContent = panel.querySelector(".dag-inspector-content") ?? null;
1516
+ const reuseShell = !initial && Boolean(existingContent);
1517
+ const nextContentSignature = dagInspectorSignature(dagRunId, node);
1518
+
1519
+ if (reuseShell && existingContent) {
1520
+ panel.classList.add("is-open");
1521
+ panel.setAttribute("aria-hidden", "false");
1522
+ syncDagInspectorHeader(panel, dagRunId, node);
1523
+ if (dagInspectorContentSignature === nextContentSignature) return;
1524
+ // Capture the reader's scroll position before repainting the content so we
1525
+ // can follow latest output when parked at the bottom, or stay put otherwise.
1526
+ const previousNode = existingContent.dataset.dagNodeId ?? "";
1527
+ const nodeChanged = previousNode !== String(node.nodeId ?? "");
1528
+ const followLatest = nodeChanged || computeFollowLatest(
1529
+ existingContent.scrollTop,
1530
+ existingContent.scrollHeight,
1531
+ existingContent.clientHeight,
1532
+ );
1533
+ const regionKey = `dag:${dagRunId}:${node.nodeId}:output`;
1534
+ dagNodeOutputViewportState = updateDagOutputViewportState(
1535
+ dagNodeOutputViewportState,
1536
+ regionKey,
1537
+ existingContent.scrollLeft,
1538
+ existingContent.scrollTop,
1539
+ followLatest,
1540
+ );
1541
+ const nestedViewportState = [...existingContent.querySelectorAll("pre")].map((pre) => ({
1542
+ scrollLeft: pre.scrollLeft,
1543
+ scrollTop: pre.scrollTop,
1544
+ }));
1545
+ clearNode(existingContent);
1546
+ fillDagInspectorContent(existingContent, dagRunId, node);
1547
+ if (followLatest) {
1548
+ existingContent.scrollTop = existingContent.scrollHeight;
1549
+ } else {
1550
+ existingContent.scrollTop = dagNodeOutputViewportState.scrollTop;
1551
+ }
1552
+ for (const [index, pre] of [...existingContent.querySelectorAll("pre")].entries()) {
1553
+ const previous = nestedViewportState[index];
1554
+ if (!previous) continue;
1555
+ pre.scrollLeft = previous.scrollLeft;
1556
+ pre.scrollTop = previous.scrollTop;
1557
+ }
1558
+ dagInspectorContentSignature = nextContentSignature;
1559
+ return;
1560
+ }
1561
+
1402
1562
  clearNode(panel);
1403
1563
  panel.classList.add("is-open");
1404
1564
  panel.setAttribute("aria-hidden", "false");
1405
1565
  panel.appendChild(createDagInspectorResizeHandle(panel));
1566
+ const header = buildDagInspectorHeader(dagRunId, node);
1567
+ panel.appendChild(header);
1568
+
1569
+ const content = el("div", "dag-inspector-content");
1570
+ content.setAttribute("role", "tabpanel");
1571
+ fillDagInspectorContent(content, dagRunId, node);
1572
+ panel.appendChild(content);
1573
+ dagInspectorContentSignature = nextContentSignature;
1574
+ }
1575
+
1576
+ function dagInspectorSignature(dagRunId, node) {
1577
+ return JSON.stringify([
1578
+ dagRunId,
1579
+ node.nodeId,
1580
+ dagInspectorTab,
1581
+ node.outputPreview ?? "",
1582
+ node.errorPreview ?? "",
1583
+ dagInspectorTab === "timeline" ? sessionEventOffset : null,
1584
+ ]);
1585
+ }
1586
+
1587
+ function buildDagInspectorHeader(dagRunId, node) {
1406
1588
  const header = el("div", "dag-inspector-header");
1589
+ header.dataset.dagRunId = String(dagRunId ?? "");
1590
+ header.dataset.dagNodeId = String(node.nodeId ?? "");
1407
1591
  const title = el("div", "dag-inspector-title");
1408
1592
  title.appendChild(el("h3", null, "节点检查器"));
1409
1593
  title.appendChild(el("div", "dag-inspector-node", node.nodeId));
1410
1594
  header.appendChild(title);
1411
1595
  const controls = el("div", "dag-inspector-controls");
1412
- controls.appendChild(badge(node.status ?? "unknown"));
1596
+ const status = badge(node.status ?? "unknown");
1597
+ status.classList.add("dag-inspector-status");
1598
+ controls.appendChild(status);
1413
1599
  const close = document.createElement("button");
1414
1600
  close.type = "button";
1415
1601
  close.className = "dag-inspector-close";
@@ -1427,6 +1613,7 @@ function renderDagInspector(dagRunId, node) {
1427
1613
  const button = document.createElement("button");
1428
1614
  button.type = "button";
1429
1615
  button.className = "dag-inspector-tab";
1616
+ button.dataset.inspectorTab = tab;
1430
1617
  button.setAttribute("role", "tab");
1431
1618
  button.setAttribute("aria-selected", String(dagInspectorTab === tab));
1432
1619
  const tabIcon = el("i", icon);
@@ -1435,15 +1622,40 @@ function renderDagInspector(dagRunId, node) {
1435
1622
  button.appendChild(document.createTextNode(` ${label}`));
1436
1623
  button.addEventListener("click", () => {
1437
1624
  dagInspectorTab = tab;
1438
- renderDagInspector(dagRunId, node);
1625
+ renderDagInspector(dagRunId, node, false);
1439
1626
  });
1440
1627
  tabs.appendChild(button);
1441
1628
  }
1442
1629
  header.appendChild(tabs);
1443
- panel.appendChild(header);
1630
+ return header;
1631
+ }
1444
1632
 
1445
- const content = el("div", "dag-inspector-content");
1446
- content.setAttribute("role", "tabpanel");
1633
+ function syncDagInspectorHeader(panel, dagRunId, node) {
1634
+ const existingHeader = panel.querySelector(":scope > .dag-inspector-header");
1635
+ if (!existingHeader) return;
1636
+ if (
1637
+ existingHeader.dataset.dagRunId !== String(dagRunId ?? "") ||
1638
+ existingHeader.dataset.dagNodeId !== String(node.nodeId ?? "")
1639
+ ) {
1640
+ panel.replaceChild(buildDagInspectorHeader(dagRunId, node), existingHeader);
1641
+ return;
1642
+ }
1643
+ const nodeLabel = existingHeader.querySelector(".dag-inspector-node");
1644
+ if (nodeLabel) nodeLabel.textContent = node.nodeId;
1645
+ const previousStatus = existingHeader.querySelector(".dag-inspector-status");
1646
+ if (previousStatus?.parentNode) {
1647
+ const nextStatus = badge(node.status ?? "unknown");
1648
+ nextStatus.classList.add("dag-inspector-status");
1649
+ previousStatus.parentNode.replaceChild(nextStatus, previousStatus);
1650
+ }
1651
+ for (const tab of existingHeader.querySelectorAll(".dag-inspector-tab")) {
1652
+ tab.setAttribute("aria-selected", String(tab.dataset.inspectorTab === dagInspectorTab));
1653
+ }
1654
+ }
1655
+
1656
+ function fillDagInspectorContent(content, dagRunId, node) {
1657
+ content.dataset.dagNodeId = String(node.nodeId ?? "");
1658
+ content.dataset.dagRunId = String(dagRunId ?? "");
1447
1659
  if (dagInspectorTab === "timeline") {
1448
1660
  renderSessionTimeline(content, node.nodeId);
1449
1661
  } else if (!node.outputPreview && !node.errorPreview) {
@@ -1457,7 +1669,6 @@ function renderDagInspector(dagRunId, node) {
1457
1669
  content.appendChild(error);
1458
1670
  }
1459
1671
  }
1460
- panel.appendChild(content);
1461
1672
  }
1462
1673
 
1463
1674
  async function renderFailures() {
@@ -2038,10 +2249,43 @@ function renderRunPanels(task, events, snapshot) {
2038
2249
 
2039
2250
  const outputLabel = cmd?.label;
2040
2251
  const tail = getOutputTail(events, outputLabel);
2041
- clearNode(outputEl);
2042
- outputEl.appendChild(el("h3", null, "最新输出"));
2043
- const pre = el("pre", "output-block", tail || "暂无输出");
2044
- outputEl.appendChild(pre);
2252
+ const existingPre = outputEl.querySelector("pre.output-block");
2253
+ let followRunOutput = true;
2254
+ let runOutputScrollLeft = 0;
2255
+ let runOutputScrollTop = 0;
2256
+ if (existingPre) {
2257
+ followRunOutput = computeFollowLatest(
2258
+ existingPre.scrollTop,
2259
+ existingPre.scrollHeight,
2260
+ existingPre.clientHeight,
2261
+ );
2262
+ runOutputScrollLeft = existingPre.scrollLeft;
2263
+ runOutputScrollTop = existingPre.scrollTop;
2264
+ }
2265
+ const nextOutput = tail || "暂无输出";
2266
+ let pre = existingPre;
2267
+ if (!pre) {
2268
+ clearNode(outputEl);
2269
+ outputEl.appendChild(el("h3", null, "最新输出"));
2270
+ pre = el("pre", "output-block", nextOutput);
2271
+ outputEl.appendChild(pre);
2272
+ } else if (pre.textContent !== nextOutput) {
2273
+ pre.textContent = nextOutput;
2274
+ }
2275
+ if (followRunOutput) {
2276
+ pre.scrollTop = pre.scrollHeight;
2277
+ } else {
2278
+ pre.scrollTop = runOutputScrollTop;
2279
+ }
2280
+ pre.scrollLeft = runOutputScrollLeft;
2281
+ const runRegionKey = `run:${task.workerRunId ?? task.taskId}:output`;
2282
+ runOutputViewportState = updateDagOutputViewportState(
2283
+ runOutputViewportState,
2284
+ runRegionKey,
2285
+ pre.scrollLeft,
2286
+ pre.scrollTop,
2287
+ followRunOutput,
2288
+ );
2045
2289
 
2046
2290
  const artifacts = collectArtifacts(task, events);
2047
2291
  clearNode(artifactsEl);
@@ -2142,6 +2386,7 @@ async function renderDagDetail(dagRunId, initial = true) {
2142
2386
  dagInspectorTab = "output";
2143
2387
  dagGraphViewportState = null;
2144
2388
  dagTimelineViewportState = null;
2389
+ dagInspectorContentSignature = null;
2145
2390
  }
2146
2391
 
2147
2392
  const metaEl = document.getElementById("dag-meta");
@@ -2182,7 +2427,19 @@ async function renderDagDetail(dagRunId, initial = true) {
2182
2427
  const succeededCount = nodes.filter((n) => isNodeFinished(n.status) && !isNodeFailed(n.status))
2183
2428
  .length;
2184
2429
 
2185
- clearNode(metaEl);
2430
+ // reuse-dag-detail-shell: on a poll refresh (non-initial) we keep the
2431
+ // interactive DOM identity stable so active scroll/resize gestures and the
2432
+ // diagnostics <details> open state survive the redraw. Initial paints and
2433
+ // DAG-run switches rebuild everything from scratch as before.
2434
+ const existingGraphWrap = ranksEl.querySelector(":scope > .dag-graph-wrap") ?? null;
2435
+ const existingViewport = existingGraphWrap?.querySelector(":scope > .dag-graph-viewport") ?? null;
2436
+ const existingDetails = metaEl.querySelector("details.meta-diagnostics") ?? null;
2437
+ const reuseShell = shouldReuseDagDetailShell(initial, Boolean(existingViewport));
2438
+ const preserveDetailsOpen = shouldPreserveDiagnosticsOpen(
2439
+ initial,
2440
+ Boolean(existingDetails?.open),
2441
+ );
2442
+
2186
2443
  const visibility = dagMetaVisibility(dag);
2187
2444
  const livenessValue = LIVENESS_LABELS[dag.liveness] ?? dag.liveness ?? LIVENESS_LABELS.unknown;
2188
2445
  const primaryEntries = [
@@ -2202,10 +2459,8 @@ async function renderDagDetail(dagRunId, initial = true) {
2202
2459
  ["成功/失败/跳过", `${succeededCount}/${failedCount}/${skippedCount}`],
2203
2460
  ["DAG 路径", dag.dagPath ?? "—"],
2204
2461
  );
2205
- metaEl.appendChild(metaGrid(primaryEntries));
2206
- const details = el("details", "meta-diagnostics");
2207
- details.appendChild(el("summary", null, "诊断信息"));
2208
- details.appendChild(metaGrid([
2462
+ const primaryGrid = metaGrid(primaryEntries);
2463
+ const diagnosticGrid = metaGrid([
2209
2464
  ["当前判定", dagStatusBadge(dag.effectiveStatus ?? dag.status)],
2210
2465
  ["原始记录状态", badge(dag.status)],
2211
2466
  ["执行器状态", livenessValue],
@@ -2213,17 +2468,41 @@ async function renderDagDetail(dagRunId, initial = true) {
2213
2468
  ["状态一致性", visibility.showConsistencyWarning ? "不一致,需要处理" : "一致"],
2214
2469
  ["允许恢复", visibility.showResume ? "是" : "否"],
2215
2470
  ["允许收口", visibility.showReconcile ? "是" : "否"],
2216
- ]));
2217
- metaEl.appendChild(details);
2471
+ ]);
2472
+ if (!initial && existingDetails?.parentNode === metaEl) {
2473
+ const existingPrimary = metaEl.querySelector(":scope > .meta-grid");
2474
+ if (existingPrimary) metaEl.replaceChild(primaryGrid, existingPrimary);
2475
+ else metaEl.insertBefore(primaryGrid, existingDetails);
2476
+ const existingDiagnosticGrid = existingDetails.querySelector(":scope > .meta-grid");
2477
+ if (existingDiagnosticGrid) {
2478
+ existingDetails.replaceChild(diagnosticGrid, existingDiagnosticGrid);
2479
+ } else {
2480
+ existingDetails.appendChild(diagnosticGrid);
2481
+ }
2482
+ existingDetails.open = preserveDetailsOpen;
2483
+ } else {
2484
+ clearNode(metaEl);
2485
+ metaEl.appendChild(primaryGrid);
2486
+ const details = el("details", "meta-diagnostics");
2487
+ details.appendChild(el("summary", null, "诊断信息"));
2488
+ details.appendChild(diagnosticGrid);
2489
+ metaEl.appendChild(details);
2490
+ }
2218
2491
 
2219
- clearNode(ranksEl);
2220
- ranksEl.appendChild(el("h3", null, "DAG 依赖图"));
2492
+ const graphGestureActive = document.body.classList.contains("is-resizing-dag-graph");
2221
2493
  if (nodes.length === 0) {
2494
+ clearNode(ranksEl);
2495
+ ranksEl.appendChild(el("h3", null, "DAG 依赖图"));
2222
2496
  ranksEl.appendChild(el("p", "empty", UI_TEXT.noNodes));
2497
+ } else if (reuseShell && existingGraphWrap?.parentNode === ranksEl) {
2498
+ if (!graphGestureActive) renderDagGraph(dag, dagRunId, existingGraphWrap);
2223
2499
  } else {
2224
- ranksEl.appendChild(renderDagGraph(dag, dagRunId));
2500
+ clearNode(ranksEl);
2501
+ ranksEl.appendChild(el("h3", null, "DAG 依赖图"));
2502
+ ranksEl.appendChild(renderDagGraph(dag, dagRunId, null));
2225
2503
  }
2226
2504
 
2505
+ const nodesScrollLeft = nodesEl.scrollLeft;
2227
2506
  clearNode(nodesEl);
2228
2507
  const nodeHeading = el("div", "dag-section-heading");
2229
2508
  nodeHeading.appendChild(el("h3", null, "节点"));
@@ -2277,6 +2556,7 @@ async function renderDagDetail(dagRunId, initial = true) {
2277
2556
  }
2278
2557
  nodesEl.appendChild(nodeTable);
2279
2558
  }
2559
+ nodesEl.scrollLeft = nodesScrollLeft;
2280
2560
 
2281
2561
  if (dagInspectorOpen && selectedDagNodeId) {
2282
2562
  await mergeSessionEvents(dagRunId, selectedDagNodeId);
@@ -2284,6 +2564,7 @@ async function renderDagDetail(dagRunId, initial = true) {
2284
2564
  renderDagInspector(
2285
2565
  dagRunId,
2286
2566
  nodes.find((node) => node.nodeId === selectedDagNodeId),
2567
+ initial,
2287
2568
  );
2288
2569
  return isDagRunActive(dag);
2289
2570
  }
@@ -451,6 +451,7 @@ tr.clickable:focus-within { outline: 2px solid var(--orange); outline-offset: -2
451
451
  .dag-inspector-resize-handle:focus-visible::before { background: var(--orange); opacity: 1; }
452
452
  .dag-inspector-resize-handle:focus-visible { outline: 2px solid var(--orange); outline-offset: -2px; }
453
453
  body.is-resizing-dag-inspector { cursor: col-resize; user-select: none; }
454
+ body.is-resizing-dag-graph { cursor: grabbing; user-select: none; }
454
455
  .dag-inspector-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: start; padding: var(--space-4) var(--space-4) var(--space-3); border-bottom: 1px solid var(--hairline); background: var(--canvas-soft); }
455
456
  .dag-inspector-title { display: grid; gap: 6px; min-width: 0; }
456
457
  .dag-inspector-title h3 { margin: 0; font-size: 14px; }
@@ -147,9 +147,6 @@ function hasPostImplementationShellVerification(spec, ranks) {
147
147
  }
148
148
  return false;
149
149
  }
150
- function hasBackendTestTopology(spec) {
151
- return spec.tasks.some((task) => /execute-backend-pytest|generate-backend-functional-cases|review-backend-cases|generate-backend-pytest|test-retrospect/i.test(task.id));
152
- }
153
150
  function hasSupervisedTopology(spec) {
154
151
  const hasSupervisor = spec.tasks.some((task) => task.role === "supervisor");
155
152
  const hasRepairWriter = spec.tasks.some((task) => isRepairNode(task) && isExclusiveWriter(task, spec));
@@ -305,10 +302,6 @@ function buildProfileReasons(profile, process, delivery, codeChange, supervisedT
305
302
  reasons.push("supervised topology detected (supervisor + repair + write-set/process/review gates)");
306
303
  return reasons;
307
304
  }
308
- if (profile === "backend-test") {
309
- reasons.push("backend-test topology detected (analyze → functional cases → review → pytest → execute → retrospect)");
310
- return reasons;
311
- }
312
305
  if (supervisedTopology) {
313
306
  reasons.push("partial supervised markers present but topology incomplete; profile downgraded");
314
307
  }
@@ -344,9 +337,6 @@ function buildProfileReasons(profile, process, delivery, codeChange, supervisedT
344
337
  return reasons;
345
338
  }
346
339
  function inferProfileName(spec, process, delivery, codeChange, ranks) {
347
- if (hasBackendTestTopology(spec)) {
348
- return "backend-test";
349
- }
350
340
  if (hasSupervisedTopology(spec)) {
351
341
  return "supervised";
352
342
  }