@tea-agent/loop-agent 0.39.0-beta.5 → 0.39.0-beta.7

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 (130) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/commands/client-recovery.js +3 -0
  4. package/dist/commands/task-advance.js +19 -0
  5. package/dist/executors/dag-pi-executor.js +138 -36
  6. package/dist/executors/pi-executor.js +7 -0
  7. package/dist/executors/pi-playwright-cli-tool.js +23 -16
  8. package/dist/executors/shell-executor.js +26 -9
  9. package/dist/shared/dag-failure-category.js +150 -0
  10. package/dist/task/config-types.js +6 -0
  11. package/dist/task/frontend-project-capability.js +19 -0
  12. package/dist/task/source-references.js +22 -1
  13. package/dist/worker/console/chat/operation-card.js +8 -1
  14. package/dist/worker/console/chat/routes.js +5 -3
  15. package/dist/worker/console/inspect-split.js +18 -0
  16. package/dist/worker/console/operation-run-facts.js +19 -3
  17. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-zsNyvGaH.js → abnfDiagram-N423BO3Z-bPer1waP.js} +1 -1
  18. package/dist/worker/console/static/assets/{arc-BDjZ5kE1.js → arc-BLdkwucW.js} +1 -1
  19. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BkgaujnN.js → architectureDiagram-T3A2C74G-Bw4wo4I1.js} +1 -1
  20. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CqxOJi0j.js → blockDiagram-VBNYF7ZC-DUUkc0gr.js} +1 -1
  21. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-C2JHLdRi.js → c4Diagram-5PPSVZJV-BC3O0nob.js} +1 -1
  22. package/dist/worker/console/static/assets/channel-AbFtVnFm.js +1 -0
  23. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DF_YSvOS.js → chunk-2GRJ4B5K-Dd4g2nti.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D3Ort5Vo.js → chunk-2Q5K7J3B-s2EDm7-n.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-XfAfZeUC.js → chunk-5RXB4S5H-GqtHM2yQ.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-SLBibhf5.js → chunk-5VM5RSS4-BzRFrN8j.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Dz29Ye_f.js → chunk-6Q2QTUOP-Bw9nFjCJ.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-DB2G4H8V.js → chunk-GF5L2VYU-B4n-03Qm.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CIoGkxdu.js → chunk-JWPE2WC7-Dt75ZdBD.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-CG78FGkn.js → chunk-KBJHAD2P-B_afMhHB.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-ZDHAvXPd.js → chunk-RYQCIY6F-Bd8Rqqg9.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BKkkC4VA.js → chunk-XXDRQBXY-9boOMji4.js} +1 -1
  33. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-n1yf2Jcv.js +1 -0
  34. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-n1yf2Jcv.js +1 -0
  35. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-SposUZsO.js → cose-bilkent-JH36ORCC-BtCycfCc.js} +1 -1
  36. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BSCtQpD7.js → cynefin-VYW2F7L2-eTOts9L1.js} +1 -1
  37. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DgFHrw9l.js → cynefinDiagram-MW4NZA55-Cibd2mjg.js} +1 -1
  38. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BVBasYbj.js → dagre-VZM6K2ZE-CDjDZtDV.js} +1 -1
  39. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-6r_4rN_c.js → diagram-7IWD3JNH-Btexn-JJ.js} +1 -1
  40. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-JmuaB41P.js → diagram-B4RE2ZJO-Cyl8sdpM.js} +1 -1
  41. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-xXvAY3sk.js → diagram-LBJQPF4R-Ev-83E1t.js} +1 -1
  42. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D2B3lY2o.js → diagram-Q27KOJAE-41LqE0Mc.js} +1 -1
  43. package/dist/worker/console/static/assets/{diagram-UB23O5K3-tK54tMEX.js → diagram-UB23O5K3-BC39T5R3.js} +1 -1
  44. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-CNtELzV-.js → ebnfDiagram-BXEA7PRR-DzcNRuri.js} +1 -1
  45. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-C1xKMKcW.js → erDiagram-JOGREHBK-B5Frvvnz.js} +1 -1
  46. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZ7dlPMz.js → flowDiagram-UKHOOZJN-BapxROIM.js} +1 -1
  47. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BqGb42Xe.js → ganttDiagram-PKOTCBZU-Cd4d4wQ0.js} +1 -1
  48. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-D_BlXEtt.js → gitGraphDiagram-DS77QQ5N-CrL6buFW.js} +1 -1
  49. package/dist/worker/console/static/assets/index-DBh_x_Ek.js +406 -0
  50. package/dist/worker/console/static/assets/index-DwOGauxU.css +1 -0
  51. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Vce2vmIe.js → infoDiagram-6WML65LV-BrSiNxyd.js} +1 -1
  52. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DDwVBAvI.js → ishikawaDiagram-WSZJBQD7-gvlvY-n9.js} +1 -1
  53. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-y_Ew-wv4.js → journeyDiagram-NVQOT4AX-D-YaPzRQ.js} +1 -1
  54. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CjXJFp1K.js → kanban-definition-27J2QSJJ-BsmDv9E_.js} +1 -1
  55. package/dist/worker/console/static/assets/{linear-1itMst1Q.js → linear-UWaoDN3h.js} +1 -1
  56. package/dist/worker/console/static/assets/{mermaid.core-C-D3ZRUa.js → mermaid.core-BlvpGVG8.js} +5 -5
  57. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BiYywmZc.js → mindmap-definition-FAOFIHXS-CUCIP5CP.js} +1 -1
  58. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-l2fydNWA.js → pegDiagram-VL7TDLO6-CB1aQUAK.js} +1 -1
  59. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-Dz0hNbYW.js → pieDiagram-7S7Q4E2Y-BwehHxhf.js} +1 -1
  60. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-BujI_mLZ.js → quadrantDiagram-CIZ2JOQS-CFz30APB.js} +1 -1
  61. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-pWe84beb.js → railroadDiagram-AXF67PYL-BwNmw1i6.js} +1 -1
  62. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-ByYu2frg.js → requirementDiagram-LRYGKXZP-jg-tPXlI.js} +1 -1
  63. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CoPnilFP.js → sankeyDiagram-W5VNT64P-BlyReDKl.js} +1 -1
  64. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-1Md2LnS0.js → sequenceDiagram-SI44F4Z6-DBUzW1f3.js} +1 -1
  65. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DqqqAoSH.js → sizeCapture-X5ZJPWSS-BYcI2RAr.js} +1 -1
  66. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-C4sGkf--.js → stateDiagram-OKZ733FA-D7AiyJdo.js} +1 -1
  67. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BBSH5c8m.js +1 -0
  68. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-ByxJrUzW.js → swimlanes-SLNWSIFB-BHGt7Kkv.js} +2 -2
  69. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D2F7-U4H.js +8 -0
  70. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-D8MLgM0O.js → timeline-definition-Z64GVDOM-OPjooWcX.js} +1 -1
  71. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DnBis-wZ.js → vennDiagram-T6HMQDX7-ggsWDvrO.js} +1 -1
  72. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-uuyYT7UO.js → wardleyDiagram-T6FBY63Y-DjNe-BjZ.js} +1 -1
  73. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-BjgabhwK.js → xychartDiagram-ELKLHX3M-Byp63Hei.js} +1 -1
  74. package/dist/worker/console/static/index.html +2 -2
  75. package/dist/worker/console/static-src/app/useRecoveryConsole.js +38 -2
  76. package/dist/worker/console/static-src/app/useRunProgress.js +46 -0
  77. package/dist/worker/console/static-src/operator-chat/cards/failure-category-advice.js +25 -0
  78. package/dist/worker/console/static-src/operator-chat/input-history.js +76 -0
  79. package/dist/worker/console/static-src/operator-chat/turn-submission.js +13 -1
  80. package/dist/worker/console/static-src/operator-chat/useChatStream.js +29 -9
  81. package/dist/worker/console/static-src/operator-chat/useComposer.js +32 -0
  82. package/dist/worker/console/static-src/pages/tasks/run-id-resolution.js +79 -0
  83. package/dist/worker/console/static-src/pages/tasks/run-ownership-verify.js +23 -0
  84. package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +110 -0
  85. package/dist/worker/materialize/harness-task-lineage.js +5 -2
  86. package/dist/worker/observability/read-model.js +22 -0
  87. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +29 -1
  88. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  89. package/dist/workflows/dag/backend-test-pytest-collection.js +87 -4
  90. package/dist/workflows/dag/backend-test-writer-completeness.js +10 -5
  91. package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
  92. package/dist/workflows/dag/dynamic-runtime/map.js +1 -1
  93. package/dist/workflows/dag/failure-category.js +7 -128
  94. package/dist/workflows/dag/frontend-design-policy.js +31 -7
  95. package/dist/workflows/dag/frontend-implementation-contract.js +78 -3
  96. package/dist/workflows/dag/frontend-review-findings.js +238 -0
  97. package/dist/workflows/dag/frontend-shadow-dual-write.js +24 -10
  98. package/dist/workflows/dag/frontend-shape.js +5 -3
  99. package/dist/workflows/dag/frontend-test-case-checklist.js +4 -2
  100. package/dist/workflows/dag/frontend-test-case-manifest.js +6 -4
  101. package/dist/workflows/dag/frontend-test-case-quality.js +6 -3
  102. package/dist/workflows/dag/frontend-test-environment-probe.js +10 -7
  103. package/dist/workflows/dag/frontend-test-html-report.js +3 -1
  104. package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
  105. package/dist/workflows/dag/frontend-test-layout.js +159 -0
  106. package/dist/workflows/dag/frontend-test-result-contract.js +28 -14
  107. package/dist/workflows/dag/frontend-test-standard-scenarios.js +3 -1
  108. package/dist/workflows/dag/init-hybrid.js +149 -15
  109. package/dist/workflows/dag/node-execution.js +18 -1
  110. package/dist/workflows/dag/report.js +37 -1
  111. package/dist/workflows/dag/rerun-feedback.js +101 -1
  112. package/dist/workflows/dag/retry-policy.js +31 -3
  113. package/dist/workflows/dag/runner.js +46 -1
  114. package/dist/workflows/dag/types.js +12 -0
  115. package/dist/workflows/dag/validate.js +32 -22
  116. package/docs/templates/agent-dag-report.schema.json +8 -2
  117. package/docs/templates/agent-dag.schema.json +1 -1
  118. package/docs/templates/frontend-test-case-checklist.md +2 -2
  119. package/package.json +1 -1
  120. package/skills/fe-test-ui-scout/SKILL.md +4 -4
  121. package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
  122. package/skills/playwright-cli/SKILL.md +1 -1
  123. package/skills/playwright-cli-case-generator/SKILL.md +10 -8
  124. package/dist/worker/console/static/assets/channel-BuqaGKAT.js +0 -1
  125. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DNdSHjH9.js +0 -1
  126. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DNdSHjH9.js +0 -1
  127. package/dist/worker/console/static/assets/index-D5zGX6fG.js +0 -404
  128. package/dist/worker/console/static/assets/index-DBbhESQ_.css +0 -1
  129. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8rmop_9.js +0 -1
  130. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B0ky-kBM.js +0 -8
@@ -58,7 +58,7 @@ function clearEmptyAssistant(setMessages, assistantId) {
58
58
  export function useChatStream(params) {
59
59
  const { origin, refs, session, thread, composer, setError, setAutoScroll, setNotice, recoveryVersion = 0, watchSessionTitle, } = params;
60
60
  const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, setTurnPhase, setDeferredTurn, } = thread;
61
- const { setInput, setPendingImages } = composer;
61
+ const { setInput, setPendingImages, recordHistory } = composer;
62
62
  const showNotice = useCallback((message) => {
63
63
  if (!setNotice)
64
64
  return;
@@ -393,7 +393,7 @@ export function useChatStream(params) {
393
393
  setInput("");
394
394
  setPendingImages([]);
395
395
  setError(null);
396
- showNotice(existing ? "已更新停止后发送的消息" : "已排队,将在上一轮停止后自动发送");
396
+ showNotice(existing ? "已更新排队消息" : "已排队,将在当前回合结束后自动发送");
397
397
  };
398
398
  const acceptDispatched = () => {
399
399
  setDeferredTurn(null);
@@ -430,7 +430,7 @@ export function useChatStream(params) {
430
430
  }
431
431
  if (body.deferredTurn)
432
432
  setDeferredTurn(body.deferredTurn);
433
- if (body.error?.code !== "TURN_NOT_STOPPING") {
433
+ if (body.error?.code !== "TURN_NOT_ACTIVE") {
434
434
  setError(body.error?.message ??
435
435
  `待发送消息未被服务端接受(HTTP ${response.status})`);
436
436
  return { action: "handled" };
@@ -495,7 +495,9 @@ export function useChatStream(params) {
495
495
  showNotice,
496
496
  fetchSessionState,
497
497
  ]);
498
- const cancelDeferredTurn = useCallback(async () => {
498
+ /** DELETE the queued turn; "restore" moves its text back into the composer,
499
+ * "discard" drops it entirely (2026-08-23 queue chip actions). */
500
+ const requestDeferredTurnDelete = useCallback(async (mode) => {
499
501
  const activeSession = refs.sessionRef.current;
500
502
  const deferred = thread.deferredTurn;
501
503
  if (!activeSession || !deferred)
@@ -519,10 +521,15 @@ export function useChatStream(params) {
519
521
  return;
520
522
  }
521
523
  setDeferredTurn(null);
522
- setInput(body.deferredTurn?.text ?? "");
523
- setPendingImages(body.deferredTurn?.images ?? []);
524
+ if (mode === "restore") {
525
+ setInput(body.deferredTurn?.text ?? "");
526
+ setPendingImages(body.deferredTurn?.images ?? []);
527
+ showNotice("待发送消息已移回输入框");
528
+ }
529
+ else {
530
+ showNotice("已删除排队消息");
531
+ }
524
532
  setError(null);
525
- showNotice("待发送消息已移回输入框");
526
533
  }
527
534
  catch (error) {
528
535
  setError(error instanceof Error ? error.message : String(error));
@@ -537,6 +544,8 @@ export function useChatStream(params) {
537
544
  setError,
538
545
  showNotice,
539
546
  ]);
547
+ const cancelDeferredTurn = useCallback(() => requestDeferredTurnDelete("restore"), [requestDeferredTurnDelete]);
548
+ const discardDeferredTurn = useCallback(() => requestDeferredTurnDelete("discard"), [requestDeferredTurnDelete]);
540
549
  const sendPrompt = useCallback(async (text, images) => {
541
550
  const initialSession = refs.sessionRef.current;
542
551
  if (!initialSession || (!text.trim() && images.length === 0))
@@ -548,10 +557,14 @@ export function useChatStream(params) {
548
557
  });
549
558
  const clientRequestId = requestId("chat-submit");
550
559
  if (route.action === "hold") {
551
- if (turnPhaseFromOwnership(ownership) === "stopping")
560
+ const phase = turnPhaseFromOwnership(ownership);
561
+ if (phase === "stopping")
552
562
  showNotice("正在确认上一轮停止状态,当前消息已保留");
563
+ else if (phase === "running")
564
+ showNotice("正在确认当前回合状态,当前消息已保留");
553
565
  return;
554
566
  }
567
+ recordHistory?.(text);
555
568
  if (route.action === "queue-deferred") {
556
569
  const deferred = await queueDeferredTurn({
557
570
  text,
@@ -721,6 +734,7 @@ export function useChatStream(params) {
721
734
  setTurnPhase,
722
735
  setStreamingAssistantId,
723
736
  queueDeferredTurn,
737
+ recordHistory,
724
738
  ]);
725
739
  const handleStop = useCallback(async () => {
726
740
  // AC-R5-001: durable stopRequested is set synchronously before any await so
@@ -969,5 +983,11 @@ export function useChatStream(params) {
969
983
  }
970
984
  };
971
985
  }, [session, recoveryVersion, refs, startEventsStream]);
972
- return { sendPrompt, handleStop, cancelDeferredTurn, openEventsStream };
986
+ return {
987
+ sendPrompt,
988
+ handleStop,
989
+ cancelDeferredTurn,
990
+ discardDeferredTurn,
991
+ openEventsStream,
992
+ };
973
993
  }
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } f
2
2
  import { filterSlashCommands, mergeSlashCommands, } from "../../chat/shortcuts.js";
3
3
  import { confirmationToken } from "./format.js";
4
4
  import { clampShortcutIndex } from "./slash-palette-nav.js";
5
+ import { createInputHistoryState, navigateInputHistory, recordInputHistoryEntry, resetInputHistoryBrowsing, } from "./input-history.js";
5
6
  import { attachRuntimeSnapshotController } from "./useRuntimeSnapshot.js";
6
7
  /** Composer state: input text, @file palette, image attachments, /shortcuts,
7
8
  * IME composition guards, auto-grow and server-side draft sync. */
@@ -31,6 +32,8 @@ export function useComposer(params) {
31
32
  const [dynamicCommandsError, setDynamicCommandsError] = useState(null);
32
33
  const [commandsEpoch, setCommandsEpoch] = useState(0);
33
34
  const textareaRef = useRef(null);
35
+ /** Arrow-key input history; unsent drafts survive history browsing. */
36
+ const historyRef = useRef(createInputHistoryState());
34
37
  /** Active session generation for /commands; bumped immediately on switch (AC-2). */
35
38
  const commandsSessionRef = useRef(sessionId);
36
39
  const commandsGenerationRef = useRef(0);
@@ -38,6 +41,33 @@ export function useComposer(params) {
38
41
  setInput(value);
39
42
  setShortcutOpen(value.startsWith("/"));
40
43
  setShortcutIndex(0);
44
+ // Manual typing edits a recalled entry in place → back to present draft.
45
+ historyRef.current = resetInputHistoryBrowsing(historyRef.current);
46
+ }, []);
47
+ /** Recall an earlier input via ↑/↓; returns true when the key was consumed. */
48
+ const navigateHistory = useCallback((direction) => {
49
+ const textarea = textareaRef.current;
50
+ const decision = navigateInputHistory(historyRef.current, direction, {
51
+ text: input,
52
+ caretStart: textarea?.selectionStart ?? input.length,
53
+ caretEnd: textarea?.selectionEnd ?? input.length,
54
+ });
55
+ if (!decision.handled)
56
+ return false;
57
+ historyRef.current = decision.state;
58
+ setInput(decision.text);
59
+ const caret = decision.text.length;
60
+ queueMicrotask(() => {
61
+ const el = textareaRef.current;
62
+ if (el && el.value === decision.text) {
63
+ el.setSelectionRange(caret, caret);
64
+ }
65
+ });
66
+ return true;
67
+ }, [input, setInput]);
68
+ /** Record a submitted text into history (consecutive duplicates collapse). */
69
+ const recordHistory = useCallback((text) => {
70
+ historyRef.current = recordInputHistoryEntry(historyRef.current, text);
41
71
  }, []);
42
72
  // Immediate invalidation on A→B even when B palette never opens (AC-2).
43
73
  useLayoutEffect(() => {
@@ -234,6 +264,8 @@ export function useComposer(params) {
234
264
  input,
235
265
  setInput,
236
266
  handleInputChange,
267
+ navigateHistory,
268
+ recordHistory,
237
269
  fileOptions,
238
270
  pendingImages,
239
271
  setPendingImages,
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Pure decision core for the TasksPage run-id resolver (2026-08-23).
3
+ *
4
+ * Extracted from the React effect so the full lifecycle — wizard first run,
5
+ * rerun dispatch, task switch, foreign shared selection, baseline-gated
6
+ * lookup — is unit-testable as state transitions without mounting React.
7
+ */
8
+ export function initialRunResolutionState() {
9
+ return { baseline: null, ownedRunId: "", pendingSelection: "" };
10
+ }
11
+ export function observeRunResolution(state, input) {
12
+ const firstObservation = state.task === undefined;
13
+ const taskChanged = !firstObservation && state.task !== input.task;
14
+ const dispatchChanged = !firstObservation && state.dispatch !== input.dispatchId;
15
+ const next = {
16
+ ...state,
17
+ task: input.task,
18
+ dispatch: input.dispatchId,
19
+ pendingSelection: "",
20
+ };
21
+ if (taskChanged) {
22
+ next.baseline = null;
23
+ next.ownedRunId = "";
24
+ return { state: next, observation: { action: "reset-selection" } };
25
+ }
26
+ if (dispatchChanged) {
27
+ next.ownedRunId = "";
28
+ if (input.selectedRunId) {
29
+ next.baseline = input.selectedRunId;
30
+ return { state: next, observation: { action: "invalidate-selection" } };
31
+ }
32
+ // Nothing shown yet: keep the existing baseline and keep probing.
33
+ }
34
+ const selected = input.selectedRunId;
35
+ if (selected && selected !== next.ownedRunId) {
36
+ next.pendingSelection = selected;
37
+ return { state: next, observation: { action: "verify-ownership" } };
38
+ }
39
+ return {
40
+ state: next,
41
+ observation: { action: selected ? "idle" : "probe" },
42
+ };
43
+ }
44
+ export function markBaselineUnknown(state) {
45
+ return { ...state, baseline: null };
46
+ }
47
+ export function applyBaselineSnapshot(state, dagRunId) {
48
+ return { ...state, baseline: dagRunId ?? "" };
49
+ }
50
+ /** Accept an observeLink lookup: only a run of this task that differs from
51
+ * the pre-dispatch baseline may be adopted — never the previous run, and
52
+ * never anything while the baseline is still unknown. */
53
+ export function applyRunLookup(state, outcome) {
54
+ if (!outcome.ok || !outcome.dagRunId) {
55
+ return { state, acceptedRunId: null };
56
+ }
57
+ if (state.baseline === null || outcome.dagRunId === state.baseline) {
58
+ return { state, acceptedRunId: null };
59
+ }
60
+ return {
61
+ state: { ...state, ownedRunId: outcome.dagRunId },
62
+ acceptedRunId: outcome.dagRunId,
63
+ };
64
+ }
65
+ /** Apply an authoritative ownership answer for the pending selection.
66
+ * `clearSelection` tells the adapter to drop the shared selection and
67
+ * snapshot a fresh baseline; owning marks it as resolver-owned. */
68
+ export function applyOwnership(state, owns) {
69
+ if (owns) {
70
+ return {
71
+ state: { ...state, ownedRunId: state.pendingSelection, pendingSelection: "" },
72
+ clearSelection: false,
73
+ };
74
+ }
75
+ return {
76
+ state: { ...state, ownedRunId: "", pendingSelection: "", baseline: null },
77
+ clearSelection: true,
78
+ };
79
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Ownership re-check backoff for an unverified shared DAG-run selection
3
+ * (2026-08-23): a transient candidates-lookup failure must neither leave
4
+ * another task's run displayed indefinitely (waiting for a state change
5
+ * that may never come) nor hammer the API.
6
+ */
7
+ const OWNERSHIP_RETRY_DELAYS_MS = [
8
+ 2_000, 2_000, 4_000, 4_000, 8_000, 8_000, 15_000, 15_000, 30_000, 30_000,
9
+ ];
10
+ /** `owns`: true = same task, false = foreign task, null = check unavailable. */
11
+ export function planOwnershipVerification(owns, attempt) {
12
+ if (owns === true)
13
+ return { kind: "own" };
14
+ if (owns === false)
15
+ return { kind: "clear" };
16
+ const delayMs = OWNERSHIP_RETRY_DELAYS_MS[attempt];
17
+ return delayMs === undefined
18
+ ? { kind: "give-up" }
19
+ : { kind: "retry", delayMs };
20
+ }
21
+ /** Total bounded re-verification window before giving up (keeps the last
22
+ * selection; the next state change restarts verification). */
23
+ export const OWNERSHIP_RETRY_WINDOW_MS = OWNERSHIP_RETRY_DELAYS_MS.reduce((total, delay) => total + delay, 0);
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Pure projection from a DAG run detail payload (GET /api/dag-runs/:id) into
3
+ * the structured run progress shown on the wizard RunPanel: node counts,
4
+ * current node + elapsed time, failure pointer, and stall signal. The panel
5
+ * used to show only a raw SSE log tail — node/phase information drowned in
6
+ * log noise during 20-40 minute runs.
7
+ */
8
+ export function classifyRunProgressNodeStatus(raw) {
9
+ const status = (raw ?? "").trim().toLowerCase();
10
+ if (!status)
11
+ return "pending";
12
+ if (status.includes("running") || status === "in_progress" || status === "active") {
13
+ return "running";
14
+ }
15
+ if (status.includes("fail") || status.includes("error") || status === "blocked") {
16
+ return "failed";
17
+ }
18
+ if (status.includes("finish") ||
19
+ status.includes("success") ||
20
+ status === "done" ||
21
+ status === "ok") {
22
+ return "finished";
23
+ }
24
+ if (status.includes("skip"))
25
+ return "skipped";
26
+ return "pending";
27
+ }
28
+ function toProgressNode(node) {
29
+ const nodeId = String(node.nodeId ?? node.id ?? "").trim();
30
+ return {
31
+ nodeId,
32
+ label: String((node.label ?? node.name ?? nodeId) || "未命名节点"),
33
+ status: String(node.status ?? ""),
34
+ rank: typeof node.rank === "string" ? node.rank : undefined,
35
+ startedAt: typeof node.startedAt === "string" ? node.startedAt : undefined,
36
+ durationMs: typeof node.durationMs === "number" ? node.durationMs : undefined,
37
+ errorPreview: typeof node.errorPreview === "string" ? node.errorPreview : undefined,
38
+ };
39
+ }
40
+ function elapsedSince(iso, now) {
41
+ if (!iso)
42
+ return null;
43
+ const started = Date.parse(iso);
44
+ if (Number.isNaN(started))
45
+ return null;
46
+ const elapsed = now - started;
47
+ return elapsed >= 0 ? elapsed : null;
48
+ }
49
+ export function summarizeRunProgress(dag, now = Date.now()) {
50
+ if (!dag) {
51
+ return {
52
+ total: 0,
53
+ finished: 0,
54
+ running: null,
55
+ failed: null,
56
+ terminal: false,
57
+ effectiveStatus: null,
58
+ stalled: false,
59
+ currentNodeElapsedMs: null,
60
+ runElapsedMs: null,
61
+ };
62
+ }
63
+ const rawNodes = Array.isArray(dag.nodes) ? dag.nodes : [];
64
+ const nodes = rawNodes.map(toProgressNode);
65
+ const classes = nodes.map((node) => classifyRunProgressNodeStatus(node.status));
66
+ const finished = classes.filter((cls) => cls === "finished" || cls === "skipped").length;
67
+ const runningIndex = classes.indexOf("running");
68
+ const running = runningIndex >= 0 ? nodes[runningIndex] : null;
69
+ const failedIndex = classes.indexOf("failed");
70
+ const failed = failedIndex >= 0 ? nodes[failedIndex] : null;
71
+ const effectiveStatus = String(dag.effectiveStatus ??
72
+ dag.status ??
73
+ "").toLowerCase();
74
+ const terminal = TERMINAL_RUN_PROGRESS_STATUSES.has(effectiveStatus);
75
+ const runStartedAt = typeof dag.startedAt === "string" ? dag.startedAt : undefined;
76
+ const runDurationMs = typeof dag.durationMs === "number" ? dag.durationMs : undefined;
77
+ return {
78
+ total: nodes.length,
79
+ finished,
80
+ running,
81
+ failed,
82
+ terminal,
83
+ effectiveStatus: effectiveStatus || null,
84
+ stalled: effectiveStatus === "running-suspected-stall",
85
+ currentNodeElapsedMs: running?.durationMs ?? elapsedSince(running?.startedAt, now),
86
+ runElapsedMs: terminal
87
+ ? (runDurationMs ?? elapsedSince(runStartedAt, now))
88
+ : elapsedSince(runStartedAt, now),
89
+ };
90
+ }
91
+ const TERMINAL_RUN_PROGRESS_STATUSES = new Set([
92
+ "finished",
93
+ "failed",
94
+ "partial_failed",
95
+ "superseded",
96
+ "abandoned",
97
+ ]);
98
+ export function formatDurationMs(ms) {
99
+ if (ms == null || !Number.isFinite(ms) || ms < 0)
100
+ return "—";
101
+ const totalSeconds = Math.floor(ms / 1000);
102
+ const hours = Math.floor(totalSeconds / 3600);
103
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
104
+ const seconds = totalSeconds % 60;
105
+ if (hours > 0)
106
+ return `${hours} 时 ${minutes} 分`;
107
+ if (minutes > 0)
108
+ return `${minutes} 分 ${seconds} 秒`;
109
+ return `${seconds} 秒`;
110
+ }
@@ -129,11 +129,14 @@ export async function resolveRevisionDigestsForTaskSpec(input) {
129
129
  };
130
130
  }
131
131
  /** Union TaskSpec allowed_paths with workflow-owned write roots. */
132
- export function expandAllowedPathsForWorkflow(workflow, allowedPaths) {
132
+ export function expandAllowedPathsForWorkflow(workflow, allowedPaths, options) {
133
133
  const extras = workflow === "backend-test"
134
134
  ? ["testcase/**", "docs/test-reports/**"]
135
135
  : workflow === "frontend-test"
136
- ? ["testcase/frontend/**", "docs/test-reports/**"]
136
+ ? [
137
+ `${options?.frontendTestRoot ?? "testcase/frontend"}/**`,
138
+ "docs/test-reports/**",
139
+ ]
137
140
  : [];
138
141
  return Array.from(new Set([...allowedPaths, ...extras]));
139
142
  }
@@ -1483,6 +1483,9 @@ function mergeDagRun(existing, incoming) {
1483
1483
  computeDurationMs(startedAt, finishedAt);
1484
1484
  return {
1485
1485
  dagRunId: incoming.dagRunId,
1486
+ ...((incoming.taskId ?? existing.taskId)
1487
+ ? { taskId: incoming.taskId ?? existing.taskId }
1488
+ : {}),
1486
1489
  status: incoming.status ?? existing.status,
1487
1490
  lifecycle: incoming.lifecycle ?? existing.lifecycle,
1488
1491
  executionMode: incoming.executionMode ?? existing.executionMode,
@@ -1914,8 +1917,10 @@ async function parseDagStateFile(statePath, now) {
1914
1917
  }
1915
1918
  : undefined;
1916
1919
  const failureCategory = readString(parsed, "failureCategory");
1920
+ const boundTaskId = await readRunSpecTaskBinding(runSpecPath);
1917
1921
  return {
1918
1922
  dagRunId,
1923
+ ...(boundTaskId ? { taskId: boundTaskId } : {}),
1919
1924
  status,
1920
1925
  ...(lifecycle ? { lifecycle } : {}),
1921
1926
  ...(executionMode ? { executionMode } : {}),
@@ -2435,6 +2440,23 @@ async function safeReadJson(filePath) {
2435
2440
  return undefined;
2436
2441
  return parsed;
2437
2442
  }
2443
+ /** Task binding for a DAG run from run.json — same precedence as
2444
+ * console/resolve-dag-run-for-task (taskContractBinding → sourceBinding →
2445
+ * Hybrid DAG title). */
2446
+ async function readRunSpecTaskBinding(runSpecPath) {
2447
+ const parsed = await safeReadJson(runSpecPath);
2448
+ if (!parsed)
2449
+ return undefined;
2450
+ const binding = parsed.taskContractBinding;
2451
+ const source = parsed.sourceBinding;
2452
+ const fromBinding = (typeof binding?.taskId === "string" && binding.taskId.trim()) ||
2453
+ (typeof source?.taskId === "string" && source.taskId.trim());
2454
+ if (fromBinding)
2455
+ return fromBinding;
2456
+ const title = typeof parsed.title === "string" ? parsed.title.trim() : "";
2457
+ const hybrid = /^Hybrid DAG:\s*(.+)$/i.exec(title);
2458
+ return hybrid?.[1]?.trim() || undefined;
2459
+ }
2438
2460
  function safeParseJson(raw) {
2439
2461
  try {
2440
2462
  const parsed = JSON.parse(raw);
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { z } from "zod";
6
6
  import YAML from "yaml";
7
7
  import { resolveBackendTestLayout, } from "./backend-test-layout.js";
8
+ import { resolveBackendTestManifestModules, backendManifestStatusFinding } from "./backend-test-markdown-workflow.js";
8
9
  import { applyOpenApiPartitionDomainPolicy, assessScenarioPartitionCoverage, parseScenarioPartitions, } from "./backend-test-scenario-partitions.js";
9
10
  import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
10
11
  const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{3})\b.*$/gm;
@@ -659,10 +660,18 @@ async function markdownModuleFiles(workspaceRoot, layout) {
659
660
  const resolved = layout ?? resolveBackendTestLayout(undefined);
660
661
  const dir = path.join(workspaceRoot, ...resolved.markdownDir.split("/"));
661
662
  const entries = await readdir(dir, { withFileTypes: true });
662
- return entries
663
+ const all = entries
663
664
  .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md") && entry.name.toLowerCase() !== "readme.md")
664
665
  .map((entry) => path.join(dir, entry.name))
665
666
  .sort();
667
+ // Current-round Markdown scope is the README Module Index manifest: unmanifested
668
+ // legacy .md files must not enter Case/coverage/partition statistics. Workspaces
669
+ // without a Module Index fall back to the whole directory (prior behaviour).
670
+ const manifest = await resolveBackendTestManifestModules(workspaceRoot, resolved);
671
+ if (manifest.manifestStatus !== "valid")
672
+ return [];
673
+ const declaredAbs = new Set(manifest.declaredMdFiles.map((rel) => path.join(workspaceRoot, ...rel.split("/")).toLowerCase()));
674
+ return all.filter((file) => declaredAbs.has(file.toLowerCase())).sort();
666
675
  }
667
676
  async function parseMarkdownCases(workspaceRoot, layout) {
668
677
  const result = [];
@@ -874,6 +883,16 @@ export async function analyzeBackendTestCaseCoverage(input) {
874
883
  findings.push(...coverageScope.findings);
875
884
  const matrix = parseCoverageMatrix(readme);
876
885
  findings.push(...matrix.findings);
886
+ const manifest = await resolveBackendTestManifestModules(input.workspaceRoot, layout);
887
+ // Fail closed on README Module Index entries declared but never materialized.
888
+ if (manifest.declaredMissing.length > 0) {
889
+ findings.push(`declared Markdown module is missing (Module Index): ${manifest.declaredMissing.join(", ")}`);
890
+ }
891
+ // Fail closed on every non-valid Module Index state: without a valid unique
892
+ // Module Index the current round must not re-scan historical `.md` files.
893
+ if (manifest.manifestStatus !== "valid") {
894
+ findings.push(backendManifestStatusFinding(manifest.manifestStatus, manifest.moduleIndexHeadingCount));
895
+ }
877
896
  const cases = await parseMarkdownCases(input.workspaceRoot, layout);
878
897
  for (const file of await markdownModuleFiles(input.workspaceRoot, layout)) {
879
898
  const markdown = await readFile(file, "utf8");
@@ -1109,6 +1128,15 @@ export async function analyzeBackendTestCaseCoverage(input) {
1109
1128
  "",
1110
1129
  facts.status,
1111
1130
  "",
1131
+ `- Manifest scope: ${manifest.manifestStatus === "valid" ? "declared-modules" : `fails-closed:${manifest.manifestStatus}`} (declared-missing=${manifest.declaredMissing.length}, unmanifested-advisory=${manifest.unmanifested.length}, module-index-heading-count=${manifest.moduleIndexHeadingCount})`,
1132
+ "",
1133
+ "## Unmanifested Markdown Modules",
1134
+ "",
1135
+ "- Advisory: existing .md files not declared by the README Module Index are excluded from Case/coverage/partition statistics for this round.",
1136
+ ...(manifest.unmanifested.length > 0
1137
+ ? manifest.unmanifested.map((item) => `- ${item}`)
1138
+ : ["- None"]),
1139
+ "",
1112
1140
  "## Coverage Scope",
1113
1141
  "",
1114
1142
  `- Change Classification: ${facts.coverageScope.changeClassification}`,