@tea-agent/loop-agent 0.43.0-next.4 → 0.43.0-next.6

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 (96) hide show
  1. package/CHANGELOG.md +82 -9
  2. package/dist/application/task-lifecycle/advance.js +17 -4
  3. package/dist/application/task-lifecycle/observe.js +43 -29
  4. package/dist/application/task-lifecycle/plan-transitions.js +5 -4
  5. package/dist/build-stamp.json +3 -3
  6. package/dist/executors/dag-pi-executor.js +233 -202
  7. package/dist/executors/pi-executor.js +2 -0
  8. package/dist/executors/pi-sdk-executor.js +77 -39
  9. package/dist/task/source-prepare/source-fidelity-pi.js +2 -2
  10. package/dist/task/source-references.js +48 -23
  11. package/dist/worker/console/chat/pi-runtime.js +113 -95
  12. package/dist/worker/console/chat/routes.js +164 -54
  13. package/dist/worker/console/chat/scm-routes.js +193 -0
  14. package/dist/worker/console/chat/scm-service.js +253 -0
  15. package/dist/worker/console/chat/scm-tools.js +111 -0
  16. package/dist/worker/console/chat/session-catalog.js +1 -0
  17. package/dist/worker/console/chat/session-mode-view.js +48 -0
  18. package/dist/worker/console/chat/session-mode.js +218 -0
  19. package/dist/worker/console/chat/session-store.js +22 -6
  20. package/dist/worker/console/chat/shortcuts.js +6 -0
  21. package/dist/worker/console/chat/tools.js +1 -0
  22. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CCj36aaC.js → abnfDiagram-N423BO3Z-BuyrlONK.js} +1 -1
  23. package/dist/worker/console/static/assets/{arc-Cl3MIVFh.js → arc-BvCl_wpi.js} +1 -1
  24. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-C_n4kMJS.js → architectureDiagram-T3A2C74G-BkBpBaRq.js} +1 -1
  25. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DlWYJ7cK.js → blockDiagram-VBNYF7ZC-yve4Mcb_.js} +1 -1
  26. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-Ud5pS7vS.js → c4Diagram-5PPSVZJV-9yLGboBx.js} +1 -1
  27. package/dist/worker/console/static/assets/channel-BRahIhjw.js +1 -0
  28. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CQPwOv_j.js → chunk-2GRJ4B5K-BKP69b8r.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CMzjxrWI.js → chunk-2Q5K7J3B-NWFRnXju.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-pcnXnD6t.js → chunk-5RXB4S5H-BiweqLEc.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CEB3AvU2.js → chunk-5VM5RSS4-CP9GHmRY.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DSKnbXbp.js → chunk-6Q2QTUOP-BiGDkBtj.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CS6-PjCZ.js → chunk-GF5L2VYU-DkJ4ZjQS.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-f6q8Tnzz.js → chunk-JWPE2WC7-B5jjn7zq.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DkB0QX3q.js → chunk-KBJHAD2P-C6KoK9O0.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BdB4jAv6.js → chunk-RYQCIY6F-BvNIfyn-.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BVm1bnX6.js → chunk-XXDRQBXY-BZ33iX4W.js} +1 -1
  38. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-GOC80mhq.js +1 -0
  39. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-GOC80mhq.js +1 -0
  40. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-FV_P-IEp.js → cose-bilkent-JH36ORCC-CP8eIuZd.js} +1 -1
  41. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BM7RtkA2.js → cynefin-VYW2F7L2-DZECjpOe.js} +1 -1
  42. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-C1BZwTG5.js → cynefinDiagram-MW4NZA55-CvVJoFqf.js} +1 -1
  43. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C6McNKmY.js → dagre-VZM6K2ZE-D_JTqdAn.js} +1 -1
  44. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-C0aWRUaE.js → diagram-7IWD3JNH-Db7AQ0z0.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Bfk_SKE8.js → diagram-B4RE2ZJO-AZPVo5CZ.js} +1 -1
  46. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-prx1T4qf.js → diagram-LBJQPF4R-BgXqI3E6.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DBvty0eU.js → diagram-Q27KOJAE-BYjFn3Ba.js} +1 -1
  48. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CF0em8e9.js → diagram-UB23O5K3-B4rJgGGO.js} +1 -1
  49. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-CF90b4Ws.js → ebnfDiagram-BXEA7PRR-C6XWZhpw.js} +1 -1
  50. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-hfunxzof.js → erDiagram-JOGREHBK-1YdQRIQW.js} +1 -1
  51. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-D-QSy4Nl.js → flowDiagram-UKHOOZJN-l3a0AwN0.js} +1 -1
  52. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DaBGNcvT.js → ganttDiagram-PKOTCBZU-CQL96F2Z.js} +1 -1
  53. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BChcubGL.js → gitGraphDiagram-DS77QQ5N-BxCvafRb.js} +1 -1
  54. package/dist/worker/console/static/assets/index-4wCGsXuU.js +465 -0
  55. package/dist/worker/console/static/assets/index-CiF9WBoR.css +1 -0
  56. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BkC67fIa.js → infoDiagram-6WML65LV-BUipIeDO.js} +1 -1
  57. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-ibhLczCD.js → ishikawaDiagram-WSZJBQD7-CRfomVMX.js} +1 -1
  58. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DCRMitay.js → journeyDiagram-NVQOT4AX-_UFvyviX.js} +1 -1
  59. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DJ_hKaYI.js → kanban-definition-27J2QSJJ-CKIRoqKv.js} +1 -1
  60. package/dist/worker/console/static/assets/{linear-CIlLsYsA.js → linear-CjttJDCM.js} +1 -1
  61. package/dist/worker/console/static/assets/{mermaid.core-C-5IQMP_.js → mermaid.core-DPvHX155.js} +5 -5
  62. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-TCzV5TQT.js → mindmap-definition-FAOFIHXS-Qmj-qwBe.js} +1 -1
  63. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-bfhd2BQH.js → pegDiagram-VL7TDLO6-Auw6DRgq.js} +1 -1
  64. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BxFRAm5N.js → pieDiagram-7S7Q4E2Y-CqMrfD4c.js} +1 -1
  65. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-C-m12lxv.js → quadrantDiagram-CIZ2JOQS-BAddeggH.js} +1 -1
  66. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CwmLiGgx.js → railroadDiagram-AXF67PYL-DG1JuOu2.js} +1 -1
  67. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-oc0Q1jiD.js → requirementDiagram-LRYGKXZP-DLYGMKlC.js} +1 -1
  68. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-9Ght6Yur.js → sankeyDiagram-W5VNT64P-g68SbLM9.js} +1 -1
  69. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-ndm3lyz9.js → sequenceDiagram-SI44F4Z6-CmszLKv8.js} +1 -1
  70. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-7sPlKVcw.js → sizeCapture-X5ZJPWSS-D2tEKUCj.js} +1 -1
  71. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Izl21-oS.js → stateDiagram-OKZ733FA-Syc5E_Jf.js} +1 -1
  72. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-CwWwuPI-.js +1 -0
  73. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CtO3D65o.js → swimlanes-SLNWSIFB-Ibn0IRCa.js} +2 -2
  74. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-A7fqemr6.js +8 -0
  75. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Cyg5cY7z.js → timeline-definition-Z64GVDOM-2ZW9W3iS.js} +1 -1
  76. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-CwtE_TFK.js → vennDiagram-T6HMQDX7-v50HsKUh.js} +1 -1
  77. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-Dtt9v8OO.js → wardleyDiagram-T6FBY63Y-DqCXEoDH.js} +1 -1
  78. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-B2Y3ryb6.js → xychartDiagram-ELKLHX3M-vIP8Vo9d.js} +1 -1
  79. package/dist/worker/console/static/index.html +2 -2
  80. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +20 -4
  81. package/dist/worker/console/static-src/operator-chat/useComposer.js +13 -7
  82. package/dist/worker/console/workspace-context.js +6 -0
  83. package/dist/workflows/dag/frontend-capacity.js +9 -0
  84. package/dist/workflows/dag/frontend-design-policy.js +2 -1
  85. package/dist/workflows/dag/frontend-plan-completeness.js +186 -0
  86. package/dist/workflows/dag/frontend-plan-recovery-policy.js +18 -0
  87. package/dist/workflows/dag/frontend-shape-facts.js +12 -2
  88. package/package.json +1 -1
  89. package/skills/frontend-plan/SKILL.md +7 -0
  90. package/dist/worker/console/static/assets/channel-CEMRpNm-.js +0 -1
  91. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C9lhaZkR.js +0 -1
  92. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C9lhaZkR.js +0 -1
  93. package/dist/worker/console/static/assets/index-CJkCY1XR.js +0 -464
  94. package/dist/worker/console/static/assets/index-DUBCbiqv.css +0 -1
  95. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-CmElLFpP.js +0 -1
  96. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DKRbGG8V.js +0 -8
@@ -9,6 +9,9 @@ import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DE
9
9
  import { serializeSessionEvent } from "./pi-event-serializer.js";
10
10
  import { PI_RECOMMENDED_COMPACTION, PI_RECOMMENDED_RETRY, } from "../shared/pi-retry-settings.js";
11
11
  import { createCompactionController, createContextPressureExtension, createSiftBridgeState, inMemoryCompactionSettings, isDagSiftEnabled, parseCompactionPolicy, parseSiftBridgeConfig, SIFT_RETRIEVE_TOOL_NAME, } from "../shared/pi-context-pressure/index.js";
12
+ /** Maximum number of same-session continuations after provider output truncation. */
13
+ export const PI_SDK_OUTPUT_LIMIT_CONTINUE_MAX = 3;
14
+ export const PI_SDK_OUTPUT_LIMIT_CONTINUE_PROMPT = "继续。上一轮因输出长度上限(stopReason=length)截断;当前会话、工具与已完成工作仍保留。只完成尚未完成的部分,不重做已成功的写入或事实提交。";
12
15
  /** Wrap the actual stream seam: tool turns, retries and compaction all await it. */
13
16
  export async function installPiProviderRequestBudget(session, reserve, onFailure, capacity) {
14
17
  const agent = session.agent;
@@ -611,6 +614,8 @@ async function executeSingleSdkAttemptInternal(options) {
611
614
  const requestEnvelopeObservation = { peakBytes: 0, peakEstimatedTokens: 0, estimatorVersion: "utf8-bytes-div3-v1-estimate" };
612
615
  const stdoutPreview = new BoundedTextPreview("stdout");
613
616
  const stdoutCollector = createPiJsonlStreamCollector();
617
+ let turnCollector = createPiJsonlStreamCollector();
618
+ let committedTerminalObserved = false;
614
619
  const usageSamples = [];
615
620
  const observationEvents = [];
616
621
  const readObservation = createFrontendReadObservation();
@@ -622,6 +627,9 @@ async function executeSingleSdkAttemptInternal(options) {
622
627
  // length-stopped thinking-only attempt with no persisted write tool calls is
623
628
  // still distinguishable from generic empty output.
624
629
  let stopReason;
630
+ let providerErrorMessage;
631
+ let promptIndex = 0;
632
+ let outputContinuesSent = 0;
625
633
  let thinkingObserved = false;
626
634
  let writeToolCallCount = 0;
627
635
  const observeWriterThinkingExhaustionEvidence = (event) => {
@@ -768,6 +776,8 @@ async function executeSingleSdkAttemptInternal(options) {
768
776
  resolveContextBudget?.("context-budget");
769
777
  };
770
778
  unsubscribe = session.subscribe((event) => {
779
+ if (event.type === "tool_execution_end" && event.isError !== true && typeof event.toolName === "string" && options.outputLimitRecovery?.terminalToolNames.includes(event.toolName) && isRecord(event.result) && isRecord(event.result.details) && event.result.details.ok === true)
780
+ committedTerminalObserved = true;
771
781
  if (event.type === "agent_settled" && !settled) {
772
782
  settled = true;
773
783
  resolveSettlement?.("settled");
@@ -788,6 +798,8 @@ async function executeSingleSdkAttemptInternal(options) {
788
798
  }
789
799
  }
790
800
  const observationMessage = isRecord(event.message) ? event.message : undefined;
801
+ if (typeof observationMessage?.errorMessage === "string")
802
+ providerErrorMessage = observationMessage.errorMessage;
791
803
  try {
792
804
  readObservation.observe(event);
793
805
  }
@@ -796,7 +808,7 @@ async function executeSingleSdkAttemptInternal(options) {
796
808
  observationEvents.push({ responseId: event.responseId, messageId: event.messageId, usage: event.usage, tokenUsage: event.tokenUsage, message: observationMessage ? { role: observationMessage.role, id: observationMessage.id, responseId: observationMessage.responseId, usage: observationMessage.usage } : undefined });
797
809
  const usageSample = extractSdkUsageSample(event);
798
810
  if (usageSample)
799
- usageSamples.push(usageSample);
811
+ usageSamples.push({ ...usageSample, responseKey: usageSample.responseKey ?? `turn-${promptIndex}` });
800
812
  if (event.type === "turn_end")
801
813
  observedTurns += 1;
802
814
  checkContextBudget();
@@ -810,6 +822,7 @@ async function executeSingleSdkAttemptInternal(options) {
810
822
  const line = serializeSessionEvent(event);
811
823
  stdoutPreview.append(`${line}\n`);
812
824
  stdoutCollector.append(`${line}\n`);
825
+ turnCollector.append(`${line}\n`);
813
826
  sessionEventAppender?.append(line, event);
814
827
  });
815
828
  const timeoutPromise = timeoutMs > 0
@@ -820,6 +833,9 @@ async function executeSingleSdkAttemptInternal(options) {
820
833
  })
821
834
  : null;
822
835
  const runTurn = async (userMessage) => {
836
+ promptIndex += 1;
837
+ stopReason = undefined;
838
+ turnCollector = createPiJsonlStreamCollector();
823
839
  const settlementPromise = resetSettlement();
824
840
  const stallPromise = resetStallPromise();
825
841
  const contextBudgetPromise = options.contextBudget
@@ -865,44 +881,55 @@ async function executeSingleSdkAttemptInternal(options) {
865
881
  : options.userMessage;
866
882
  let continuesSent = 0;
867
883
  let raced = await runTurn(promptMessage);
868
- if (raced === "context-budget") {
869
- appendStderr(contextBudgetDetail);
870
- abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
871
- await disposeSession();
872
- }
873
- else if (raced === "stall") {
874
- abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
875
- const withinAbsoluteTimeout = timeoutMs <= 0 || Date.now() - startedAt < timeoutMs;
876
- const canContinue = abortConfirmed &&
877
- writeToolCallCount === 0 &&
878
- withinAbsoluteTimeout &&
879
- continuesSent < PI_SDK_STALL_CONTINUE_MAX;
880
- if (canContinue) {
881
- // The aborted turn's stopReason must not leak onto a recovered continue.
882
- stopReason = undefined;
883
- continuesSent += 1;
884
- persistRuntimeNote({
885
- type: "loop-agent-stall-continue",
886
- prompt: PI_SDK_STALL_CONTINUE_PROMPT,
887
- maxContinues: PI_SDK_STALL_CONTINUE_MAX,
888
- });
889
- raced = await runTurn(PI_SDK_STALL_CONTINUE_PROMPT);
890
- if (raced !== "done" && raced !== "settled") {
891
- await failClosedAfterWait(raced === "stall" ? "stall" : "absolute-timeout", raced === "stall"
892
- ? "pi SDK stall continue did not recover; treating as timeout"
893
- : "pi SDK stall continue hit the absolute timeout", true);
884
+ while (true) {
885
+ if (raced === "context-budget") {
886
+ appendStderr(contextBudgetDetail);
887
+ abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
888
+ await disposeSession();
889
+ }
890
+ else if (raced === "stall") {
891
+ abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
892
+ const withinAbsoluteTimeout = timeoutMs <= 0 || Date.now() - startedAt < timeoutMs;
893
+ const canContinue = abortConfirmed &&
894
+ writeToolCallCount === 0 &&
895
+ withinAbsoluteTimeout &&
896
+ continuesSent < PI_SDK_STALL_CONTINUE_MAX;
897
+ if (canContinue) {
898
+ // The aborted turn's stopReason must not leak onto a recovered continue.
899
+ stopReason = undefined;
900
+ continuesSent += 1;
901
+ persistRuntimeNote({
902
+ type: "loop-agent-stall-continue",
903
+ prompt: PI_SDK_STALL_CONTINUE_PROMPT,
904
+ maxContinues: PI_SDK_STALL_CONTINUE_MAX,
905
+ });
906
+ raced = await runTurn(PI_SDK_STALL_CONTINUE_PROMPT);
907
+ if (raced !== "done" && raced !== "settled") {
908
+ await failClosedAfterWait(raced === "stall" ? "stall" : "absolute-timeout", raced === "stall"
909
+ ? "pi SDK stall continue did not recover; treating as timeout"
910
+ : "pi SDK stall continue hit the absolute timeout", true);
911
+ }
912
+ }
913
+ else {
914
+ await failClosedAfterWait("stall", !abortConfirmed
915
+ ? undefined
916
+ : writeToolCallCount > 0
917
+ ? "pi SDK stall continue skipped because write tools already ran"
918
+ : "pi SDK stall continue skipped because absolute timeout elapsed", false);
894
919
  }
895
920
  }
896
- else {
897
- await failClosedAfterWait("stall", !abortConfirmed
898
- ? undefined
899
- : writeToolCallCount > 0
900
- ? "pi SDK stall continue skipped because write tools already ran"
901
- : "pi SDK stall continue skipped because absolute timeout elapsed", false);
921
+ else if (raced === "absolute-timeout") {
922
+ await failClosedAfterWait("absolute-timeout");
902
923
  }
903
- }
904
- else if (raced === "absolute-timeout") {
905
- await failClosedAfterWait("absolute-timeout");
924
+ if (options.outputLimitRecovery && stopReason === "length" && !committedTerminalObserved && !stderr && outputContinuesSent < PI_SDK_OUTPUT_LIMIT_CONTINUE_MAX && !timedOut) {
925
+ outputContinuesSent += 1;
926
+ const note = serializeSessionEvent({ type: "loop-agent-output-limit-continue", continue: outputContinuesSent, maxContinues: PI_SDK_OUTPUT_LIMIT_CONTINUE_MAX, prompt: PI_SDK_OUTPUT_LIMIT_CONTINUE_PROMPT });
927
+ stdoutPreview.append(`${note}\n`);
928
+ stdoutCollector.append(`${note}\n`);
929
+ raced = await runTurn(PI_SDK_OUTPUT_LIMIT_CONTINUE_PROMPT);
930
+ continue;
931
+ }
932
+ break;
906
933
  }
907
934
  }
908
935
  catch (error) {
@@ -931,20 +958,29 @@ async function executeSingleSdkAttemptInternal(options) {
931
958
  }
932
959
  }
933
960
  }
961
+ if (providerErrorMessage && stopReason === "error" && !stderr)
962
+ appendStderr(providerErrorMessage);
963
+ if (options.outputLimitRecovery && stopReason === "length" && !committedTerminalObserved && outputContinuesSent >= PI_SDK_OUTPUT_LIMIT_CONTINUE_MAX) {
964
+ const note = serializeSessionEvent({ type: "loop-agent-output-limit-exhausted", maxContinues: PI_SDK_OUTPUT_LIMIT_CONTINUE_MAX });
965
+ stdoutPreview.append(`${note}\n`);
966
+ }
934
967
  const stdout = stdoutPreview.text();
935
968
  const durationMs = Date.now() - startedAt;
936
969
  const collected = stdoutCollector.finish();
937
970
  const fallbackParsed = collected.parsedEvents > 0
938
971
  ? undefined
939
972
  : extractAssistantTextFromPiJson(stdout);
973
+ const finalTurn = turnCollector.finish();
940
974
  const assistantText = collected.parsedEvents > 0
941
- ? collected.assistantText
975
+ ? finalTurn.assistantText
942
976
  : (fallbackParsed?.assistantText ?? "");
943
977
  const parsedEvents = collected.parsedEvents > 0
944
978
  ? collected.parsedEvents
945
979
  : (fallbackParsed?.parsedEvents ?? 0);
946
980
  const tokensUsed = aggregateSdkTokenUsage(usageSamples);
947
- const failureCategory = !terminationConfirmed ? "termination-unconfirmed" : providerBudgetFailure ? (providerBudgetFailure.includes("REQUEST_ABORTED") ? "interrupted" : providerBudgetFailure.startsWith("FRONTEND_CONTEXT_ESTIMATE_EXCEEDED") ? "context-budget-exhausted" : "budget_breach") : terminationConfirmed
981
+ const cancelled = stopReason === "aborted";
982
+ const outputLimitExhausted = Boolean(options.outputLimitRecovery && stopReason === "length" && !committedTerminalObserved && !timedOut && !stderr);
983
+ const failureCategory = !terminationConfirmed ? "termination-unconfirmed" : stopReason === "aborted" ? "cancelled" : outputLimitExhausted ? "output-limit" : providerBudgetFailure ? (providerBudgetFailure.includes("REQUEST_ABORTED") ? "interrupted" : providerBudgetFailure.startsWith("FRONTEND_CONTEXT_ESTIMATE_EXCEEDED") ? "context-budget-exhausted" : "budget_breach") : terminationConfirmed
948
984
  ? classifyPiFailure({
949
985
  assistantText,
950
986
  exitCode: timedOut ? 1 : stderr ? 1 : 0,
@@ -959,12 +995,14 @@ async function executeSingleSdkAttemptInternal(options) {
959
995
  backend: "sdk",
960
996
  command: ["pi-sdk", ...piSdkArgs],
961
997
  durationMs,
962
- exitCode: timedOut || stderr ? 1 : 0,
998
+ exitCode: timedOut || stderr || outputLimitExhausted || cancelled ? 1 : 0,
963
999
  failureCategory,
964
1000
  modelDisplay,
965
1001
  ok: terminationConfirmed &&
966
1002
  !timedOut &&
967
1003
  !stderr &&
1004
+ !outputLimitExhausted &&
1005
+ !cancelled &&
968
1006
  assistantText.length > 0 &&
969
1007
  !collected.outputTooLarge,
970
1008
  parsedEvents,
@@ -69,7 +69,7 @@ const reviewResponseSchema = z.object({
69
69
  disposition: z.enum(FRAGMENT_DISPOSITIONS).optional(),
70
70
  reason: z.string().min(1).optional(),
71
71
  })
72
- .optional(),
72
+ .nullish(),
73
73
  });
74
74
  /**
75
75
  * 模型队列构建镜像 semantic-intake.ts:MED→HIGH→LOW(harness executors.pi)+
@@ -134,7 +134,7 @@ function buildReviewPrompt(request) {
134
134
  "- approved: true only when the exclusion/conflict handling is justified.",
135
135
  "- fragmentId: MUST equal the requested fragment id exactly (echo the `fragment id:` line verbatim); identity mismatch is rejected.",
136
136
  "- reason: recommended; explain the verdict.",
137
- "- revision: optional single bounded revision {disposition, reason}; never invent requirements or human resolutions.",
137
+ "- revision: omit or use null when no revision is proposed; otherwise one bounded revision {disposition, reason}. Never invent requirements or human resolutions.",
138
138
  JSON.stringify({
139
139
  fragmentId: "REQ-SRC-AB12CD34EF56",
140
140
  approved: true,
@@ -5,7 +5,7 @@
5
5
  * source/references/, separate from the derived source/需求.md contract.
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
- import { access, copyFile, mkdir, readdir, readFile, writeFile, } from "node:fs/promises";
8
+ import { access, copyFile, lstat, mkdir, readdir, readFile, writeFile, } from "node:fs/promises";
9
9
  import path from "node:path";
10
10
  import { getTaskPaths, loadTaskConfig } from "./runtime.js";
11
11
  import { writeTaskConfig } from "../infrastructure/harness/task-store.js";
@@ -87,6 +87,7 @@ export async function materializeTaskReferenceDocs(input) {
87
87
  const referenceDir = path.join(paths.sourceDir, SOURCE_REFERENCE_DIRECTORY);
88
88
  await mkdir(referenceDir, { recursive: true });
89
89
  const materialized = [];
90
+ const manifest = await readSourceManifest(paths.sourceDir);
90
91
  for (const doc of docs) {
91
92
  const absoluteSource = path.isAbsolute(doc.path)
92
93
  ? doc.path
@@ -94,7 +95,10 @@ export async function materializeTaskReferenceDocs(input) {
94
95
  await assertReadableFile(absoluteSource, `referenceDocs entry "${doc.name ?? doc.path}"`);
95
96
  const extension = path.extname(absoluteSource) || ".md";
96
97
  const baseName = slugifyName(doc.name ?? path.basename(absoluteSource, extension));
97
- const targetName = `${baseName}${extension}`;
98
+ const imported = manifest?.documents.find((entry) => path.basename(entry.materializedPath, path.extname(entry.materializedPath)) === baseName);
99
+ const targetName = imported
100
+ ? path.basename(imported.materializedPath)
101
+ : `${baseName}${extension}`;
98
102
  const targetPath = path.join(referenceDir, targetName);
99
103
  try {
100
104
  await access(targetPath);
@@ -132,14 +136,23 @@ export async function importPrdDocument(input) {
132
136
  catch {
133
137
  // directory may be empty / just created
134
138
  }
135
- // Prefer stable name "requirement.md" when free; otherwise suffix.
136
- const preferredName = `${requestedName}${extension}`;
137
- const targetFileName = existingNames.has(preferredName)
138
- ? // If the same content is already there, reuse; else unique name.
139
- (await sameHashIfExists(path.join(referenceDir, preferredName), hash.sha256))
140
- ? preferredName
141
- : ensureUniqueReferenceName(existingNames, requestedName, extension)
142
- : preferredName;
139
+ // Search every immutable reference, including suffixed and renamed imports.
140
+ // Roles remain distinct: identical bytes must not erase a constraint/AC role.
141
+ const matching = existingManifest?.documents.find((doc) => doc.role === role && doc.sha256 === hash.sha256 && doc.bytes === hash.bytes);
142
+ if (matching) {
143
+ const candidate = path.resolve(paths.sourceDir, matching.materializedPath);
144
+ if (path.dirname(candidate) !== path.resolve(referenceDir)) {
145
+ throw new Error(`REFERENCE_PATH_UNSAFE: ${matching.materializedPath}`);
146
+ }
147
+ const stats = await lstat(candidate);
148
+ if (!stats.isFile() || stats.isSymbolicLink() ||
149
+ !(await sameHashIfExists(candidate, hash.sha256))) {
150
+ throw new Error(`REFERENCE_HASH_MISMATCH: ${matching.materializedPath}`);
151
+ }
152
+ }
153
+ const targetFileName = matching
154
+ ? path.basename(matching.materializedPath)
155
+ : ensureUniqueReferenceName(existingNames, requestedName, extension);
143
156
  const targetPath = path.join(referenceDir, targetFileName);
144
157
  if (!(await fileExists(targetPath))) {
145
158
  await writeFile(targetPath, hash.content);
@@ -149,7 +162,7 @@ export async function importPrdDocument(input) {
149
162
  ? toPosixRelative(input.repoRoot, absoluteSource)
150
163
  : input.filePath.split(path.sep).join("/");
151
164
  const materializedRelativePath = `${SOURCE_REFERENCE_DIRECTORY}/${targetFileName}`;
152
- const document = {
165
+ const document = matching ?? {
153
166
  role,
154
167
  name: requestedName,
155
168
  sourcePath: sourcePathForRecord,
@@ -176,20 +189,28 @@ export async function importPrdDocument(input) {
176
189
  : (existingManifest?.updatedAt ?? importedAt),
177
190
  documents,
178
191
  };
179
- await writeSourceManifest(paths.sourceDir, manifest);
192
+ if (documentSetChanged)
193
+ await writeSourceManifest(paths.sourceDir, manifest);
180
194
  const taskConfig = await loadTaskConfig(input.repoRoot, input.taskId);
181
195
  const referenceDocs = [...(taskConfig.referenceDocs ?? [])];
182
- const alreadyListed = referenceDocs.some((doc) => {
196
+ const listedIndex = referenceDocs.findIndex((doc) => {
183
197
  const absolute = path.isAbsolute(doc.path)
184
198
  ? doc.path
185
199
  : path.resolve(input.repoRoot, doc.path);
186
200
  return absolute === absoluteSource;
187
201
  });
188
- if (!alreadyListed) {
189
- referenceDocs.push({
190
- name: requestedName,
191
- path: sourcePathForRecord,
192
- });
202
+ // Keep original source paths as provenance, but point materialization at
203
+ // the chosen immutable filename so DAG generation cannot recreate duplicates.
204
+ const reference = {
205
+ name: path.basename(targetFileName, path.extname(targetFileName)),
206
+ path: sourcePathForRecord,
207
+ };
208
+ const configChanged = listedIndex < 0 || referenceDocs[listedIndex]?.name !== reference.name;
209
+ if (configChanged) {
210
+ if (listedIndex < 0)
211
+ referenceDocs.push(reference);
212
+ else
213
+ referenceDocs[listedIndex] = { ...referenceDocs[listedIndex], ...reference };
193
214
  await writeTaskConfig(input.repoRoot, input.taskId, {
194
215
  ...taskConfig,
195
216
  referenceDocs,
@@ -198,11 +219,15 @@ export async function importPrdDocument(input) {
198
219
  // Managed contracts must bump revision after import-prd mutates manifest/task config.
199
220
  // Lazy import avoids circular init with contract/observe.
200
221
  const { refreshManagedContractAfterImport } = await import("./contract/import-revision.js");
201
- await refreshManagedContractAfterImport({
202
- repoRoot: input.repoRoot,
203
- taskId: input.taskId,
204
- });
222
+ const changed = documentSetChanged || configChanged;
223
+ if (changed) {
224
+ await refreshManagedContractAfterImport({
225
+ repoRoot: input.repoRoot,
226
+ taskId: input.taskId,
227
+ });
228
+ }
205
229
  return {
230
+ changed,
206
231
  taskId: input.taskId,
207
232
  sourcePath: sourcePathForRecord,
208
233
  materializedAbsolutePath: targetPath,
@@ -210,7 +235,7 @@ export async function importPrdDocument(input) {
210
235
  sha256: hash.sha256,
211
236
  bytes: hash.bytes,
212
237
  manifestPath: getSourceManifestPath(paths.sourceDir),
213
- referenceDocs: alreadyListed ? referenceDocs : [...referenceDocs],
238
+ referenceDocs,
214
239
  };
215
240
  }
216
241
  async function sameHashIfExists(filePath, sha256) {