@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.
- package/CHANGELOG.md +25 -0
- package/README.md +3 -2
- package/dist/application/dag/generate-task-dag.js +15 -0
- package/dist/application/dag/run-dag.js +10 -0
- package/dist/application/dag/validate-dag.js +11 -0
- package/dist/commands/init.js +74 -7
- package/dist/shared/package-metadata.js +135 -0
- package/dist/task/config-types.js +1 -0
- package/dist/worker/cli.js +3 -1
- package/dist/worker/observability/event-history.js +216 -0
- package/dist/worker/observability/read-model.js +312 -83
- package/dist/worker/observe/paths.js +17 -0
- package/dist/worker/observe/routes.js +165 -21
- package/dist/worker/observe/server.js +59 -1
- package/dist/worker/observe/static/api.js +27 -0
- package/dist/worker/observe/static/app.js +120 -2598
- package/dist/worker/observe/static/constants.js +148 -0
- package/dist/worker/observe/static/copy.js +67 -0
- package/dist/worker/observe/static/dag-helpers.js +172 -0
- package/dist/worker/observe/static/dag-model.js +72 -0
- package/dist/worker/observe/static/dom.js +61 -0
- package/dist/worker/observe/static/format-pool.js +67 -0
- package/dist/worker/observe/static/format.js +292 -0
- package/dist/worker/observe/static/index.html +300 -82
- package/dist/worker/observe/static/kpi.js +94 -0
- package/dist/worker/observe/static/relations.js +128 -0
- package/dist/worker/observe/static/router.js +85 -0
- package/dist/worker/observe/static/run-processing.js +148 -0
- package/dist/worker/observe/static/shell-chrome.js +68 -0
- package/dist/worker/observe/static/state.js +253 -0
- package/dist/worker/observe/static/styles.css +1719 -495
- package/dist/worker/observe/static/views/batch.js +226 -0
- package/dist/worker/observe/static/views/dag-graph.js +172 -0
- package/dist/worker/observe/static/views/dag-inspector.js +477 -0
- package/dist/worker/observe/static/views/dag.js +362 -0
- package/dist/worker/observe/static/views/dashboard.js +442 -0
- package/dist/worker/observe/static/views/failures.js +143 -0
- package/dist/worker/observe/static/views/feature.js +453 -0
- package/dist/worker/observe/static/views/pool.js +347 -0
- package/dist/worker/observe/static/views/run.js +453 -0
- package/dist/worker/observe/static/views/session-timeline.js +205 -0
- package/dist/worker/observe/static/views/shell.js +7 -0
- package/dist/worker/observe/static/views/task.js +260 -0
- package/dist/worker/observe/static/views/timeline.js +163 -0
- package/dist/workflows/dag/controller-identity.js +104 -0
- package/dist/workflows/dag/init-hybrid.js +396 -3
- package/dist/workflows/dag/node-execution.js +123 -29
- package/dist/workflows/dag/repair-artifact.js +91 -0
- package/dist/workflows/dag/report.js +50 -0
- package/dist/workflows/dag/retry-policy.js +138 -0
- package/dist/workflows/dag/runner.js +32 -0
- package/dist/workflows/dag/runtime-contract.js +87 -0
- package/dist/workflows/dag/skill-snapshot.js +2 -0
- package/dist/workflows/dag/types.js +44 -1
- package/dist/workflows/dag/validate.js +68 -4
- package/docs/agent-dag-runner.md +26 -1
- package/docs/architecture/dag-execution.md +6 -0
- package/docs/architecture/evolution.md +4 -3
- package/docs/architecture/facts-and-state.md +1 -1
- package/docs/design/README.md +4 -3
- package/docs/exec-plans/active/README.md +1 -3
- package/docs/exec-plans/completed/README.md +11 -0
- package/docs/feature-workflow.md +28 -0
- package/docs/progress/README.md +18 -0
- package/docs/reports/README.md +8 -2
- package/docs/templates/agent-dag-report.schema.json +17 -0
- package/docs/templates/agent-dag.schema.json +69 -1
- package/docs/templates/agent-dag.supervised-implementation.json +8 -2
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
- package/docs/templates/backend-test-dag.json +276 -0
- package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
- package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -0
- package/skills/loop-agent/references/hybrid-dag.md +22 -3
- 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:
|
|
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:
|
|
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:
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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,
|