@tea-agent/loop-agent 0.11.0 → 0.12.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 (76) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +3 -2
  3. package/dist/application/dag/generate-task-dag.js +15 -0
  4. package/dist/application/dag/run-dag.js +10 -0
  5. package/dist/application/dag/validate-dag.js +11 -0
  6. package/dist/commands/init.js +74 -7
  7. package/dist/shared/package-metadata.js +135 -0
  8. package/dist/task/config-types.js +1 -0
  9. package/dist/worker/cli.js +3 -1
  10. package/dist/worker/observability/event-history.js +216 -0
  11. package/dist/worker/observability/read-model.js +312 -83
  12. package/dist/worker/observe/paths.js +17 -0
  13. package/dist/worker/observe/routes.js +165 -21
  14. package/dist/worker/observe/server.js +59 -1
  15. package/dist/worker/observe/static/api.js +27 -0
  16. package/dist/worker/observe/static/app.js +120 -2598
  17. package/dist/worker/observe/static/constants.js +148 -0
  18. package/dist/worker/observe/static/copy.js +67 -0
  19. package/dist/worker/observe/static/dag-helpers.js +172 -0
  20. package/dist/worker/observe/static/dag-model.js +72 -0
  21. package/dist/worker/observe/static/dom.js +61 -0
  22. package/dist/worker/observe/static/format-pool.js +67 -0
  23. package/dist/worker/observe/static/format.js +292 -0
  24. package/dist/worker/observe/static/index.html +300 -82
  25. package/dist/worker/observe/static/kpi.js +94 -0
  26. package/dist/worker/observe/static/relations.js +128 -0
  27. package/dist/worker/observe/static/router.js +85 -0
  28. package/dist/worker/observe/static/run-processing.js +148 -0
  29. package/dist/worker/observe/static/shell-chrome.js +68 -0
  30. package/dist/worker/observe/static/state.js +253 -0
  31. package/dist/worker/observe/static/styles.css +1719 -495
  32. package/dist/worker/observe/static/views/batch.js +226 -0
  33. package/dist/worker/observe/static/views/dag-graph.js +172 -0
  34. package/dist/worker/observe/static/views/dag-inspector.js +477 -0
  35. package/dist/worker/observe/static/views/dag.js +362 -0
  36. package/dist/worker/observe/static/views/dashboard.js +442 -0
  37. package/dist/worker/observe/static/views/failures.js +143 -0
  38. package/dist/worker/observe/static/views/feature.js +453 -0
  39. package/dist/worker/observe/static/views/pool.js +347 -0
  40. package/dist/worker/observe/static/views/run.js +453 -0
  41. package/dist/worker/observe/static/views/session-timeline.js +205 -0
  42. package/dist/worker/observe/static/views/shell.js +7 -0
  43. package/dist/worker/observe/static/views/task.js +260 -0
  44. package/dist/worker/observe/static/views/timeline.js +163 -0
  45. package/dist/workflows/dag/controller-identity.js +104 -0
  46. package/dist/workflows/dag/init-hybrid.js +396 -3
  47. package/dist/workflows/dag/node-execution.js +123 -29
  48. package/dist/workflows/dag/repair-artifact.js +91 -0
  49. package/dist/workflows/dag/report.js +50 -0
  50. package/dist/workflows/dag/retry-policy.js +138 -0
  51. package/dist/workflows/dag/runner.js +32 -0
  52. package/dist/workflows/dag/runtime-contract.js +87 -0
  53. package/dist/workflows/dag/skill-snapshot.js +2 -0
  54. package/dist/workflows/dag/types.js +44 -1
  55. package/dist/workflows/dag/validate.js +68 -4
  56. package/docs/agent-dag-runner.md +26 -1
  57. package/docs/architecture/dag-execution.md +6 -0
  58. package/docs/architecture/evolution.md +4 -3
  59. package/docs/architecture/facts-and-state.md +1 -1
  60. package/docs/design/README.md +4 -3
  61. package/docs/exec-plans/active/README.md +1 -3
  62. package/docs/exec-plans/completed/README.md +11 -0
  63. package/docs/feature-workflow.md +28 -0
  64. package/docs/progress/README.md +18 -0
  65. package/docs/reports/README.md +8 -2
  66. package/docs/templates/agent-dag-report.schema.json +17 -0
  67. package/docs/templates/agent-dag.schema.json +69 -1
  68. package/docs/templates/agent-dag.supervised-implementation.json +8 -2
  69. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
  70. package/docs/templates/backend-test-dag.json +276 -0
  71. package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
  72. package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
  73. package/package.json +1 -1
  74. package/skills/loop-agent/references/command-reference.md +1 -0
  75. package/skills/loop-agent/references/hybrid-dag.md +22 -3
  76. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
@@ -2,9 +2,10 @@ import { access, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { assertValidDagSpec } from "./validate.js";
5
- import { DEFAULT_DAG_OUTPUT_LANGUAGE, DEFAULT_DAG_EXECUTOR_MODELS, parseDagSpec, } from "./types.js";
5
+ import { DAG_AGENT_RUNTIME_PI_ONLY, DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1, DAG_RUNTIME_CONTRACT_SCHEMA_VERSION, DEFAULT_DAG_OUTPUT_LANGUAGE, DEFAULT_DAG_EXECUTOR_MODELS, parseDagSpec, } from "./types.js";
6
6
  import { pathMatchesPattern } from "../../shared/git-progress.js";
7
7
  import { BASELINE_FORBIDDEN_PATHS } from "./governance-constants.js";
8
+ import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, isSafeReadOnlyPiRetryCandidate } from "./retry-policy.js";
8
9
  import { resolveAdapter } from "../../adapters/index.js";
9
10
  import { loadHarnessManifest } from "../../governance/harness.js";
10
11
  import { buildAuthoritySurfaceAuditNode, buildAuthoritySurfaceGateNode, resolveAuthoritySurfaceAudit, } from "./authority-surface.js";
@@ -77,6 +78,18 @@ const FRONTEND_DESIGN_REVIEW_SKILLS = ["frontend-design-review"];
77
78
  const FRONTEND_REVIEW_SKILLS = ["frontend-review"];
78
79
  const FRONTEND_VERIFICATION_SKILLS = ["frontend-verification"];
79
80
  const IMPLEMENT_WRITESET_PLACEHOLDER = "REPLACE/WITH/NARROW/IMPLEMENT/PATHS/**";
81
+ /**
82
+ * Runtime contract stamped on every newly generated DAG. Capability fields are
83
+ * authoritative and are the primary compatibility gate. `minimumControllerVersion`
84
+ * is intentionally omitted here: it is an optional diagnostic hint, and hardcoding
85
+ * a future version would make a controller reject the very DAGs it generates.
86
+ * Operators can still pin a minimum version on hand-authored specs when needed.
87
+ */
88
+ export const GENERATED_DAG_RUNTIME_CONTRACT = {
89
+ schemaVersion: DAG_RUNTIME_CONTRACT_SCHEMA_VERSION,
90
+ agentRuntime: DAG_AGENT_RUNTIME_PI_ONLY,
91
+ repairWriterProtocol: DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1,
92
+ };
80
93
  function resolveEnabledExecutors(manifestExecutors) {
81
94
  return Object.entries(manifestExecutors)
82
95
  .filter(([, config]) => config.enabled !== false)
@@ -605,8 +618,9 @@ export function buildStandardHybridDagFromTask(sources) {
605
618
  ...STANDARD_GLOBAL_CONSTRAINTS,
606
619
  ];
607
620
  const spec = {
608
- version: 2,
621
+ version: 3,
609
622
  title: `Hybrid DAG: ${taskConfig.title}`,
623
+ runtimeContract: GENERATED_DAG_RUNTIME_CONTRACT,
610
624
  outputLanguage: sources.outputLanguage ?? DEFAULT_DAG_OUTPUT_LANGUAGE,
611
625
  objective: extractObjective(sources.requirementMarkdown, taskConfig.title),
612
626
  successCriteria: extractSuccessCriteria(sources.requirementMarkdown, sources.taskId),
@@ -739,6 +753,7 @@ export function buildStandardHybridDagFromTask(sources) {
739
753
  ],
740
754
  };
741
755
  applySddEmbeddedEnhancements(spec, sources.sddEmbeddedSkills ?? new Set());
756
+ applyDefaultReadOnlyRetryPolicy(spec);
742
757
  parseDagSpec(spec);
743
758
  assertValidDagSpec(spec);
744
759
  return spec;
@@ -778,8 +793,9 @@ function buildFrontendHybridDagFromTask(sources) {
778
793
  adapterCommands: sources.verifyCommands?.final,
779
794
  });
780
795
  const spec = {
781
- version: 2,
796
+ version: 3,
782
797
  title: `Frontend implementation DAG: ${taskConfig.title}`,
798
+ runtimeContract: GENERATED_DAG_RUNTIME_CONTRACT,
783
799
  objective: extractObjective(sources.requirementMarkdown, taskConfig.title),
784
800
  successCriteria: extractSuccessCriteria(sources.requirementMarkdown, sources.taskId),
785
801
  globalConstraints,
@@ -1028,6 +1044,361 @@ function buildFrontendHybridDagFromTask(sources) {
1028
1044
  },
1029
1045
  ],
1030
1046
  };
1047
+ applyDefaultReadOnlyRetryPolicy(spec);
1048
+ parseDagSpec(spec);
1049
+ assertValidDagSpec(spec);
1050
+ return spec;
1051
+ }
1052
+ // ---------------------------------------------------------------------------
1053
+ // Backend test DAG template
1054
+ // ---------------------------------------------------------------------------
1055
+ function buildAnalyzeInputsNode(sources) {
1056
+ return {
1057
+ id: "analyze-inputs-pi",
1058
+ depends_on: [],
1059
+ role: "planner",
1060
+ executor: "pi",
1061
+ complexity: "MED",
1062
+ writePolicy: "read-only",
1063
+ allowedPaths: commonReadOnlyPaths(sources),
1064
+ forbiddenPaths: commonForbiddenPaths(sources),
1065
+ outputContract: "Structured Markdown extracting core content from source documents. No file writes.",
1066
+ subtask_prompt: [
1067
+ "Read the task source materials and extract the following structured content for downstream test generation.",
1068
+ "",
1069
+ "## Required Output Sections:",
1070
+ "",
1071
+ "### 1. API Endpoints",
1072
+ "List all API endpoints: Method, Path, Description, Request params, Response format.",
1073
+ "",
1074
+ "### 2. Data Model",
1075
+ "For each table/collection: fields, types, constraints, descriptions.",
1076
+ "",
1077
+ "### 3. Business Logic",
1078
+ "Core business rules, validation rules, calculation formulas.",
1079
+ "",
1080
+ "### 4. State Transitions",
1081
+ "State machines (e.g. order status: pending → paid → shipped → completed).",
1082
+ "",
1083
+ "### 5. Error Scenarios & Error Codes",
1084
+ "All error codes, error messages, and when they occur.",
1085
+ "",
1086
+ "### 6. External Dependencies",
1087
+ "Third-party services, databases, message queues. Include timeout settings if documented.",
1088
+ "",
1089
+ "### 7. Acceptance Criteria",
1090
+ "Extract ALL acceptance criteria from 需求.md. Number them AC-001, AC-002, etc. If not explicitly listed, derive from functional requirements.",
1091
+ "",
1092
+ "### 8. Risk Areas",
1093
+ "High-risk areas requiring extra test coverage.",
1094
+ "",
1095
+ "## Conditional Sections (include ONLY if mentioned in requirements):",
1096
+ "- Authentication & Authorization: include ONLY if requirements mention auth mechanism (JWT, OAuth2, API Key, etc.)",
1097
+ "- Timeout Handling: include ONLY if requirements mention timeout configuration or degradation strategy",
1098
+ "- Concurrency & Idempotency: include ONLY if requirements mention concurrency, idempotency rules, or locking mechanisms",
1099
+ "- State Transitions: include ONLY if requirements mention business state machines",
1100
+ "- If not mentioned in requirements, do NOT include these sections",
1101
+ "",
1102
+ "This output will be used directly by downstream nodes. Be thorough and structured.",
1103
+ "Read-only: do not modify code, docs, artifacts, or repository files.",
1104
+ buildSourceContextBlock(sources),
1105
+ ].join("\n\n"),
1106
+ };
1107
+ }
1108
+ function buildGenerateBackendFunctionalCasesNode(sources) {
1109
+ return {
1110
+ id: "generate-backend-functional-cases-pi",
1111
+ depends_on: ["analyze-inputs-pi"],
1112
+ role: "implementer",
1113
+ executor: "pi",
1114
+ toolProfile: "write",
1115
+ complexity: "MED",
1116
+ writePolicy: "exclusive",
1117
+ writeSet: ["testcase/md/**"],
1118
+ allowedPaths: ["testcase/md/**"],
1119
+ forbiddenPaths: commonForbiddenPaths(sources),
1120
+ // 注意:Pi 节点超时由 executor 层控制(默认 30 分钟)
1121
+ // 如需调整,在 harness.json 的 executors.pi 中配置 modelConfig.timeoutMs
1122
+ subtask_prompt: [
1123
+ "Based on the upstream analyze-inputs-pi output, generate structured backend functional test cases.",
1124
+ "",
1125
+ "## Output Steps (do in order):",
1126
+ "1. First, output a brief summary: how many modules, how many cases planned per module",
1127
+ "2. Then write each test case file under testcase/md/",
1128
+ "",
1129
+ "## Format Rules:",
1130
+ "- Each test case ID: BE-<MODULE>-<NNN> (e.g. BE-ORDER-001)",
1131
+ "- Each file covers one module",
1132
+ "- Case structure: ID, Title, Precondition, Steps, Expected Result",
1133
+ "- Map each case to acceptance criteria (AC-xxx)",
1134
+ "",
1135
+ "## Coverage Requirements:",
1136
+ "- Positive paths: happy path for each acceptance criterion",
1137
+ "- Negative paths: error scenarios (invalid input, not found, state violations)",
1138
+ "- Boundary conditions: empty input, max length, edge values",
1139
+ "",
1140
+ "## Conditional Coverage (include ONLY if mentioned in upstream analysis):",
1141
+ "- State transitions: include ONLY if upstream analyze-inputs-pi mentions state machine",
1142
+ "- Authentication scenarios: include ONLY if upstream analyze-inputs-pi mentions auth mechanism",
1143
+ "- Timeout scenarios: include ONLY if upstream analyze-inputs-pi mentions timeout handling",
1144
+ "- Concurrency scenarios: include ONLY if upstream analyze-inputs-pi mentions concurrency/idempotency rules",
1145
+ "- If not mentioned, do NOT generate these test cases",
1146
+ "",
1147
+ "## Constraints:",
1148
+ "- Stay within writeSet: testcase/md/**",
1149
+ "- Do NOT re-read source documents — use the upstream analyze-inputs-pi output only",
1150
+ "- Do not write root artifacts/**",
1151
+ ].join("\n\n"),
1152
+ };
1153
+ }
1154
+ function buildReviewBackendCasesNode(sources) {
1155
+ return {
1156
+ id: "review-backend-cases-pi",
1157
+ depends_on: ["generate-backend-functional-cases-pi"],
1158
+ role: "reviewer",
1159
+ executor: "pi",
1160
+ complexity: "HIGH",
1161
+ writePolicy: "read-only",
1162
+ allowedPaths: commonReadOnlyPaths(sources),
1163
+ forbiddenPaths: commonForbiddenPaths(sources),
1164
+ outputContract: "Plain Markdown whose first non-empty line is VERDICT: pass or VERDICT: request-revision; followed by Findings and Coverage Assessment. No file writes.",
1165
+ subtask_prompt: [
1166
+ "Review the generated backend functional test cases under testcase/md/.",
1167
+ "",
1168
+ "## Mandatory First Line:",
1169
+ "First non-empty line must be exactly: VERDICT: pass or VERDICT: request-revision",
1170
+ "",
1171
+ "## Review Checklist:",
1172
+ "- ID format: every case uses BE-<MODULE>-<NNN>",
1173
+ "- Positive coverage: each acceptance criterion (AC-xxx) has happy-path case",
1174
+ "- Negative coverage: error scenarios (invalid input, not found, state violations)",
1175
+ "- Boundary coverage: edge cases (empty, max length, edge values)",
1176
+ "- Traceability: each AC maps to at least one case ID",
1177
+ "- Case structure: ID, Title, Precondition, Steps, Expected Result",
1178
+ "- No duplicate IDs across files",
1179
+ "",
1180
+ "## Conditional Coverage (check ONLY if mentioned in upstream analysis):",
1181
+ "- State transition coverage: check ONLY if analyze-inputs-pi mentions state machine",
1182
+ "- Authentication coverage: check ONLY if analyze-inputs-pi mentions auth mechanism",
1183
+ "- Timeout coverage: check ONLY if analyze-inputs-pi mentions timeout handling",
1184
+ "- Concurrency coverage: check ONLY if analyze-inputs-pi mentions concurrency/idempotency rules",
1185
+ "- If not mentioned, do NOT flag as missing",
1186
+ "",
1187
+ "## Verdict Rules:",
1188
+ "- All Critical checks pass + Important findings ≤ 2 → VERDICT: pass",
1189
+ "- Any Critical fails OR Important > 2 → VERDICT: request-revision",
1190
+ "",
1191
+ "## Output After Verdict:",
1192
+ "1. Coverage Assessment table (AC → case IDs)",
1193
+ "2. Findings list (Critical/Important/Informational)",
1194
+ "3. Statistics (total cases, positive/negative/boundary breakdown)",
1195
+ "",
1196
+ "## Constraints:",
1197
+ "- Read-only: do not modify files",
1198
+ "- Do NOT re-read source documents — use upstream analyze-inputs-pi output for acceptance criteria",
1199
+ "- Use testcase/md/ files for case review",
1200
+ ].join("\n\n"),
1201
+ };
1202
+ }
1203
+ function buildReviewBackendCasesGateNode(sources) {
1204
+ return {
1205
+ id: "review-backend-cases-gate-shell",
1206
+ depends_on: ["review-backend-cases-pi"],
1207
+ role: "verifier",
1208
+ executor: "shell",
1209
+ complexity: "LOW",
1210
+ writePolicy: "read-only",
1211
+ allowedPaths: commonReadOnlyPaths(sources),
1212
+ forbiddenPaths: commonForbiddenPaths(sources),
1213
+ outputContract: "Deterministic backend case review gate: exit 0 only when review-backend-cases-pi emits VERDICT: pass.",
1214
+ subtask_prompt: "Deterministic gate: block pytest generation unless backend case review emitted VERDICT: pass.",
1215
+ shell: {
1216
+ commands: [],
1217
+ verdictGate: {
1218
+ fromNodeId: "review-backend-cases-pi",
1219
+ accept: ["VERDICT: pass"],
1220
+ label: "backend case review",
1221
+ lineMode: "first-verdict-line",
1222
+ },
1223
+ cwd: ".",
1224
+ timeoutMs: 60000,
1225
+ },
1226
+ };
1227
+ }
1228
+ function buildGenerateBackendPytestNode(sources) {
1229
+ return {
1230
+ id: "generate-backend-pytest-pi",
1231
+ depends_on: ["review-backend-cases-gate-shell"],
1232
+ role: "implementer",
1233
+ executor: "pi",
1234
+ toolProfile: "write",
1235
+ complexity: "HIGH",
1236
+ writePolicy: "exclusive",
1237
+ writeSet: ["testcase/**/test_*.py"],
1238
+ allowedPaths: ["testcase/**/test_*.py"],
1239
+ forbiddenPaths: commonForbiddenPaths(sources),
1240
+ // 注意:Pi 节点超时由 executor 层控制(默认 30 分钟)
1241
+ // 如需调整,在 harness.json 的 executors.pi 中配置 modelConfig.timeoutMs
1242
+ subtask_prompt: [
1243
+ "Convert the reviewed test cases under testcase/md/ into pytest automation code.",
1244
+ "",
1245
+ "## Output Steps (do in order):",
1246
+ "1. First, output a brief summary: how many files, how many test functions planned",
1247
+ "2. Then write each test file under testcase/",
1248
+ "",
1249
+ "## Format Rules:",
1250
+ "- File prefix: test_<module>.py",
1251
+ "- Function name: test_BE_<MODULE>_<NNN>_<description>",
1252
+ "- Docstring first line: BE-<MODULE>-<NNN>: <Case Title>",
1253
+ "- 1:1 mapping: each functional case → one pytest function",
1254
+ "",
1255
+ "## Implementation Rules:",
1256
+ "- Use assert statements, not unittest assertions",
1257
+ "- Assert specific values, not just 'no exception'",
1258
+ "- Use @pytest.mark.parametrize for boundary cases",
1259
+ "- Use markers: @pytest.mark.positive, @pytest.mark.negative, @pytest.mark.boundary",
1260
+ "",
1261
+ "## Conditional Implementation (include ONLY if test cases exist):",
1262
+ "- Authentication tests: implement ONLY if testcase/md/ contains auth-related cases",
1263
+ "- Timeout tests: implement ONLY if testcase/md/ contains timeout-related cases",
1264
+ "- Use @pytest.mark.auth for auth tests, @pytest.mark.timeout for timeout tests",
1265
+ "- If no such cases exist, do NOT add these tests",
1266
+ "",
1267
+ "## Constraints:",
1268
+ "- Only create NEW files, do NOT modify existing framework files (conftest.py, pytest.ini, pyproject.toml)",
1269
+ "- If filename exists, add suffix: test_order.py → test_order_01.py",
1270
+ "- Stay within writeSet: testcase/**/test_*.py",
1271
+ "- Do NOT re-read source documents — use the reviewed cases under testcase/md/ only",
1272
+ "- Read existing conftest.py/pytest.ini to understand conventions, but do NOT modify them",
1273
+ ].join("\n\n"),
1274
+ };
1275
+ }
1276
+ function buildExecuteBackendPytestNode(sources) {
1277
+ return {
1278
+ id: "execute-backend-pytest-shell",
1279
+ depends_on: ["generate-backend-pytest-pi"],
1280
+ role: "verifier",
1281
+ executor: "shell",
1282
+ complexity: "LOW",
1283
+ writePolicy: "read-only",
1284
+ allowedPaths: commonReadOnlyPaths(sources),
1285
+ forbiddenPaths: commonForbiddenPaths(sources),
1286
+ outputContract: "Archived pytest stdout/stderr with exit codes and HTML report path; no source or test file modifications.",
1287
+ subtask_prompt: "Run pytest for the backend test suite and capture results.",
1288
+ shell: {
1289
+ commands: [
1290
+ "python -m pytest testcase/ --html=reports/backend-test-report.html -v",
1291
+ ],
1292
+ verifyEvidence: buildVerifyEvidence({
1293
+ phase: "final",
1294
+ quota: "full",
1295
+ commandSource: "inline",
1296
+ fallbackCommands: [
1297
+ "python -m pytest testcase/ --html=reports/backend-test-report.html -v",
1298
+ ],
1299
+ finalFullRequired: true,
1300
+ }),
1301
+ cwd: ".",
1302
+ timeoutMs: 300000,
1303
+ },
1304
+ };
1305
+ }
1306
+ function buildTestRetrospectNode(sources) {
1307
+ return {
1308
+ id: "test-retrospect-pi",
1309
+ depends_on: ["execute-backend-pytest-shell"],
1310
+ role: "closeout",
1311
+ executor: "pi",
1312
+ toolProfile: "write",
1313
+ complexity: "MED",
1314
+ writePolicy: "exclusive",
1315
+ writeSet: ["docs/test-reports/**"],
1316
+ allowedPaths: ["docs/test-reports/**"],
1317
+ forbiddenPaths: commonForbiddenPaths(sources),
1318
+ subtask_prompt: [
1319
+ "Read upstream outputs (review report + pytest results) and generate a test retrospective report.",
1320
+ "",
1321
+ "## Output Steps (do in order):",
1322
+ "1. First, output the maturity rating on the first line: Rating: A/B/C/D",
1323
+ "2. Then write the full report under docs/test-reports/",
1324
+ "",
1325
+ "## Report Structure:",
1326
+ "1. Maturity Rating with rationale",
1327
+ "2. Test Coverage Summary (total cases, pass rate, failed case analysis)",
1328
+ "3. Review Findings and resolution status",
1329
+ "4. Failed Test Analysis (if any)",
1330
+ "5. Recommendations for improvement",
1331
+ "",
1332
+ "## Rating Criteria:",
1333
+ "- A: 100% acceptance criteria covered + 100% pytest pass + no Critical findings",
1334
+ "- B: ≥80% coverage + ≥90% pass + Low findings only",
1335
+ "- C: ≥60% coverage + ≥70% pass + no Critical findings",
1336
+ "- D: below C thresholds",
1337
+ "",
1338
+ "## Constraints:",
1339
+ "- Stay within writeSet: docs/test-reports/**",
1340
+ "- Do NOT re-read source documents — use upstream outputs only",
1341
+ "- Do not write root artifacts/**",
1342
+ ].join("\n\n"),
1343
+ };
1344
+ }
1345
+ const BACKEND_TEST_DEFAULTS = {
1346
+ ...HYBRID_DEFAULTS,
1347
+ writePolicy: "read-only",
1348
+ };
1349
+ const BACKEND_TEST_SKILLS_BY_ROLE = {
1350
+ planner: ["loop-agent"],
1351
+ scout: [],
1352
+ implementer: ["test-driven-development", "verification-before-completion"],
1353
+ reviewer: ["requesting-code-review", "code-review-core"],
1354
+ verifier: ["verification-before-completion", "systematic-debugging"],
1355
+ closeout: ["loop-agent", "verification-before-completion"],
1356
+ };
1357
+ function buildBackendTestHybridDag(sources) {
1358
+ const { taskConfig } = sources;
1359
+ const sourceContext = buildSourceContextBlock(sources);
1360
+ const readOnlyPaths = commonReadOnlyPaths(sources);
1361
+ const forbiddenPaths = commonForbiddenPaths(sources);
1362
+ const globalConstraints = [
1363
+ ...taskConfig.hardConstraints,
1364
+ ...(sources.constraintMarkdown
1365
+ ? [`See 执行约束.md in task source (${sources.taskId})`]
1366
+ : []),
1367
+ ...STANDARD_GLOBAL_CONSTRAINTS,
1368
+ "backend-test-dag nodes must maintain traceability from requirements to functional cases to pytest automation.",
1369
+ "Functional test case IDs must use BE-<MODULE>-<NNN> format.",
1370
+ "pytest execution must produce HTML reports under reports/.",
1371
+ "pytest automation scripts must use test_ filename prefix for pytest discovery.",
1372
+ "generate-backend-pytest-pi must only create new test files under testcase/; modifying existing framework files (conftest.py, pytest.ini, pyproject.toml) is forbidden.",
1373
+ "review-backend-cases-gate-shell must block pytest generation unless the review verdict is exactly VERDICT: pass.",
1374
+ "If a target test filename already exists under testcase/, add a numeric suffix (_01, _02, ...); never overwrite or append to existing files.",
1375
+ "execute-backend-pytest-shell must not modify test assertions or production code to make tests pass; test failures indicate potential implementation issues and must be reported honestly.",
1376
+ ];
1377
+ const spec = {
1378
+ version: 3,
1379
+ title: `Backend test DAG: ${taskConfig.title}`,
1380
+ runtimeContract: GENERATED_DAG_RUNTIME_CONTRACT,
1381
+ outputLanguage: sources.outputLanguage ?? DEFAULT_DAG_OUTPUT_LANGUAGE,
1382
+ objective: extractObjective(sources.requirementMarkdown, taskConfig.title),
1383
+ successCriteria: extractSuccessCriteria(sources.requirementMarkdown, sources.taskId),
1384
+ globalConstraints,
1385
+ defaults: {
1386
+ ...BACKEND_TEST_DEFAULTS,
1387
+ contextProfile: taskConfig.contextProfile,
1388
+ },
1389
+ skillsByRole: BACKEND_TEST_SKILLS_BY_ROLE,
1390
+ executorModels: sources.executorModelMatrix ?? DEFAULT_DAG_EXECUTOR_MODELS,
1391
+ tasks: [
1392
+ buildAnalyzeInputsNode(sources),
1393
+ buildGenerateBackendFunctionalCasesNode(sources),
1394
+ buildReviewBackendCasesNode(sources),
1395
+ buildReviewBackendCasesGateNode(sources),
1396
+ buildGenerateBackendPytestNode(sources),
1397
+ buildExecuteBackendPytestNode(sources),
1398
+ buildTestRetrospectNode(sources),
1399
+ ],
1400
+ };
1401
+ applyDefaultReadOnlyRetryPolicy(spec);
1031
1402
  parseDagSpec(spec);
1032
1403
  assertValidDagSpec(spec);
1033
1404
  return spec;
@@ -1037,6 +1408,10 @@ export function buildHybridDagFromTask(sources, options = {}) {
1037
1408
  options.template === "frontend-implementation") {
1038
1409
  return buildFrontendHybridDagFromTask(sources);
1039
1410
  }
1411
+ if (sources.taskConfig.taskKind === "backend-test" ||
1412
+ options.template === "backend-test-dag") {
1413
+ return buildBackendTestHybridDag(sources);
1414
+ }
1040
1415
  const standard = buildStandardHybridDagFromTask(sources);
1041
1416
  const template = options.template ?? "standard-dag";
1042
1417
  if (template === "standard-dag")
@@ -1048,6 +1423,21 @@ export function buildHybridDagFromTask(sources, options = {}) {
1048
1423
  function cloneTask(task, patch = {}) {
1049
1424
  return { ...task, ...patch };
1050
1425
  }
1426
+ /**
1427
+ * Apply the default read-only Pi retry policy to safe planner/scout/reviewer/
1428
+ * verifier/closeout Pi nodes in the generated DAG. Writers, supervisors,
1429
+ * dynamic, shell, static, and decision-gate nodes are skipped. Idempotent:
1430
+ * never overwrites an explicit retryPolicy a task already declares.
1431
+ */
1432
+ function applyDefaultReadOnlyRetryPolicy(spec) {
1433
+ for (const task of spec.tasks) {
1434
+ if (task.retryPolicy !== undefined)
1435
+ continue;
1436
+ if (isSafeReadOnlyPiRetryCandidate(task)) {
1437
+ task.retryPolicy = DEFAULT_READ_ONLY_PI_RETRY_POLICY;
1438
+ }
1439
+ }
1440
+ }
1051
1441
  function getTaskOrThrow(spec, id) {
1052
1442
  const task = spec.tasks.find((item) => item.id === id);
1053
1443
  if (!task)
@@ -1135,6 +1525,7 @@ function buildReviewGatedHybridDag(standard, sources) {
1135
1525
  replaceTask(spec, cloneTask(closeout, { depends_on: ["review-gate-shell"] }));
1136
1526
  spec.tasks.splice(spec.tasks.length - 1, 0, buildReviewNode(sources), buildReviewGateNode(sources));
1137
1527
  applySddEmbeddedEnhancements(spec, sources.sddEmbeddedSkills ?? new Set());
1528
+ applyDefaultReadOnlyRetryPolicy(spec);
1138
1529
  parseDagSpec(spec);
1139
1530
  assertValidDagSpec(spec);
1140
1531
  return spec;
@@ -1263,6 +1654,7 @@ function buildProcessGateNode(sources) {
1263
1654
  },
1264
1655
  repairArtifactGate: {
1265
1656
  fromNodeId: "process-supervisor-pi",
1657
+ repairNodeId: repairNodeId(),
1266
1658
  },
1267
1659
  cwd: ".",
1268
1660
  timeoutMs: 60000,
@@ -1405,6 +1797,7 @@ function buildSupervisedHybridDag(standard, sources) {
1405
1797
  ],
1406
1798
  };
1407
1799
  applySddEmbeddedEnhancements(spec, sources.sddEmbeddedSkills ?? new Set());
1800
+ applyDefaultReadOnlyRetryPolicy(spec);
1408
1801
  parseDagSpec(spec);
1409
1802
  assertValidDagSpec(spec);
1410
1803
  return spec;
@@ -3,10 +3,12 @@ import { recordDecisionEnvelopeForNode, shouldPauseOnHumanEscalation, writeHuman
3
3
  import { writeNodeRecord, writeNodeSkillArtifacts } from "./run-store.js";
4
4
  import { buildDagNodePromptEnvelope } from "./prompt.js";
5
5
  import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
6
+ import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
7
+ import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
8
  import { assertSkillSnapshotCoversSpec, buildNodePromptFromSnapshot, isDagSkillSnapshotIntegrityError, readSkillSnapshot, } from "./skill-snapshot.js";
7
9
  import { resolveDagSkillInstructions, skillInstructionMetadata, } from "./skill-instructions.js";
8
10
  import { resolveDagNodeSkills } from "./skills.js";
9
- import { parseRepairArtifactFromText, validateRepairArtifactScope, } from "./repair-artifact.js";
11
+ import { parseRepairArtifactFromText, resolveRepairTaskForGate, validateRepairArtifactScope, } from "./repair-artifact.js";
10
12
  import { resolveModelForTask, } from "./types.js";
11
13
  export function buildNodePrompt(spec, task, upstream) {
12
14
  return buildDagNodePromptEnvelope({
@@ -54,10 +56,6 @@ function assertRepairArtifactVerdictMatchesSupervisor(input) {
54
56
  throw new Error(`repair artifact gate failed: verdict mismatch between supervisor ${supervisorVerdict} and REPAIR_ARTIFACT_JSON ${input.artifactVerdict}`);
55
57
  }
56
58
  }
57
- function findRepairTaskForGate(input) {
58
- return Array.from(input.tasksById.values()).find((candidate) => candidate.depends_on.includes(input.gateTask.id) &&
59
- candidate.id === "repair-pi");
60
- }
61
59
  function parseSupervisorRepairArtifact(node) {
62
60
  const text = `${node?.assistantText ?? ""}\n${node?.stdout ?? ""}`;
63
61
  return parseRepairArtifactFromText(text);
@@ -76,12 +74,21 @@ function validateRepairArtifactGateBeforeShell(input) {
76
74
  artifactVerdict: parsed.artifact.verdict,
77
75
  });
78
76
  upstream.repairArtifact = parsed.artifact;
77
+ const resolution = resolveRepairTaskForGate({
78
+ tasks: Array.from(input.tasksById.values()),
79
+ gateTask: input.task,
80
+ });
81
+ if (!resolution.ok) {
82
+ // A pass verdict does not need a repair writer; only fail closed when the
83
+ // supervisor requested a revision that must be routed to a repair node.
84
+ if (parsed.artifact.verdict === "request-revision") {
85
+ throw new Error(`repair artifact gate failed: ${resolution.reason}`);
86
+ }
87
+ return;
88
+ }
79
89
  const scoped = validateRepairArtifactScope({
80
90
  artifact: parsed.artifact,
81
- repairTask: findRepairTaskForGate({
82
- tasksById: input.tasksById,
83
- gateTask: input.task,
84
- }),
91
+ repairTask: resolution.repairTask,
85
92
  });
86
93
  if (!scoped.ok) {
87
94
  throw new Error(`repair artifact gate failed: ${scoped.reason}`);
@@ -96,6 +103,15 @@ function recordRepairArtifactForSupervisorNode(input) {
96
103
  input.node.repairArtifact = parsed.artifact;
97
104
  }
98
105
  }
106
+ function sleep(ms) {
107
+ return new Promise((resolve) => setTimeout(resolve, ms));
108
+ }
109
+ function sumAttemptMetric(attempts, select) {
110
+ const values = attempts.map(select).filter((value) => value !== undefined);
111
+ return values.length > 0
112
+ ? values.reduce((sum, value) => sum + value, 0)
113
+ : undefined;
114
+ }
99
115
  async function notifyNodeObserver(observer, event, nodeId, state, chunk) {
100
116
  try {
101
117
  if (event === "onNodeOutput") {
@@ -223,31 +239,109 @@ export async function executeDagNode(input) {
223
239
  node.resolvedSkills = resolvedSkills;
224
240
  await writeNodeSkillArtifacts(runDir, nodeId, resolvedSkills);
225
241
  const model = resolveModelForTask(task, spec.executorModels);
226
- const started = Date.now();
227
- try {
228
- validateRepairArtifactGateBeforeShell({
229
- task,
230
- tasksById,
231
- state,
232
- });
233
- const result = await executeNode({ task, cwd, model, prompt });
234
- node.durationMs = result.durationMs ?? Date.now() - started;
242
+ const retryPolicy = task.retryPolicy && isSafeReadOnlyPiRetryCandidate(task)
243
+ ? task.retryPolicy
244
+ : undefined;
245
+ const maxAttempts = retryPolicy?.maxAttempts ?? 1;
246
+ const attempts = [];
247
+ let totalBackoffMs = 0;
248
+ let terminalResult;
249
+ for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
250
+ const attemptStartedAt = new Date().toISOString();
251
+ const attemptStarted = Date.now();
252
+ let result;
253
+ try {
254
+ validateRepairArtifactGateBeforeShell({
255
+ task,
256
+ tasksById,
257
+ state,
258
+ });
259
+ result = await executeNode({ task, cwd, model, prompt });
260
+ }
261
+ catch (error) {
262
+ result = {
263
+ ok: false,
264
+ stdout: "",
265
+ stderr: error instanceof Error ? error.message : String(error),
266
+ durationMs: Date.now() - attemptStarted,
267
+ };
268
+ }
269
+ const attemptFinishedAt = new Date().toISOString();
270
+ const attemptRecord = {
271
+ attempt: attemptNumber,
272
+ startedAt: attemptStartedAt,
273
+ finishedAt: attemptFinishedAt,
274
+ durationMs: result.durationMs ?? Date.now() - attemptStarted,
275
+ ok: result.ok,
276
+ stdout: result.stdout,
277
+ stderr: result.stderr,
278
+ assistantText: result.assistantText,
279
+ failureCategory: result.failureCategory,
280
+ backend: result.backend,
281
+ sdkAttempted: result.sdkAttempted,
282
+ tokensUsed: result.tokensUsed,
283
+ parsedEvents: result.parsedEvents,
284
+ artifactPath: `${nodeId}/attempt-${attemptNumber}.json`,
285
+ };
286
+ if (retryPolicy !== undefined) {
287
+ attempts.push(attemptRecord);
288
+ // Persist immutable attempt evidence BEFORE waiting for the next attempt
289
+ // and before applying the result to the node record, so a later success
290
+ // never overwrites prior failure evidence.
291
+ await writeDagNodeJsonArtifact(runDir, nodeId, `attempt-${attemptNumber}.json`, attemptRecord);
292
+ node.attempts = attempts;
293
+ }
294
+ // Reflect the latest attempt on the node so progress is observable,
295
+ // but keep node.status RUNNING while retry is still possible.
296
+ node.durationMs =
297
+ retryPolicy === undefined
298
+ ? attemptRecord.durationMs
299
+ : attempts.reduce((sum, attempt) => sum + (attempt.durationMs ?? 0), 0) +
300
+ totalBackoffMs;
235
301
  node.stdout = result.stdout;
236
302
  node.stderr = result.stderr;
237
303
  node.failureCategory = result.failureCategory;
238
- if (result.assistantText !== undefined) {
239
- node.assistantText = result.assistantText;
240
- }
241
- if (result.backend !== undefined)
242
- node.backend = result.backend;
243
- if (result.sdkAttempted !== undefined) {
244
- node.sdkAttempted = result.sdkAttempted;
304
+ node.assistantText = result.assistantText;
305
+ node.backend = result.backend;
306
+ node.sdkAttempted = result.sdkAttempted;
307
+ node.tokensUsed =
308
+ retryPolicy === undefined
309
+ ? result.tokensUsed
310
+ : sumAttemptMetric(attempts, (attempt) => attempt.tokensUsed);
311
+ node.parsedEvents =
312
+ retryPolicy === undefined
313
+ ? result.parsedEvents
314
+ : sumAttemptMetric(attempts, (attempt) => attempt.parsedEvents);
315
+ node.lastActivityAt = attemptFinishedAt;
316
+ if (retryPolicy !== undefined) {
317
+ state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
318
+ await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
319
+ await input.persistState();
245
320
  }
246
- if (result.tokensUsed !== undefined)
247
- node.tokensUsed = result.tokensUsed;
248
- if (result.parsedEvents !== undefined) {
249
- node.parsedEvents = result.parsedEvents;
321
+ terminalResult = result;
322
+ if (result.ok)
323
+ break;
324
+ const canRetry = retryPolicy !== undefined && attemptNumber < maxAttempts;
325
+ const retryable = retryPolicy !== undefined &&
326
+ isRetryablePiFailureCategory(result.failureCategory, {
327
+ retryCategories: retryPolicy.retryCategories,
328
+ });
329
+ if (!canRetry || !retryable)
330
+ break;
331
+ const delayMs = computeBackoffDelayMs(attemptNumber + 1, retryPolicy);
332
+ if (delayMs > 0) {
333
+ totalBackoffMs += delayMs;
334
+ await sleep(delayMs);
335
+ // Keep lastActivityAt fresh so backoff wait is not misread as node-quiet.
336
+ node.lastActivityAt = new Date().toISOString();
337
+ state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
338
+ await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
339
+ await input.persistState();
250
340
  }
341
+ }
342
+ const result = terminalResult;
343
+ const started = Date.now();
344
+ try {
251
345
  recordRepairArtifactForSupervisorNode({
252
346
  task,
253
347
  node,