@tea-agent/loop-agent 0.34.0 → 0.34.1
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 +30 -0
- package/dist/application/task-lifecycle/advance.js +57 -7
- package/dist/application/task-lifecycle/plan-transitions.js +13 -21
- package/dist/executors/dag-pi-executor.js +18 -3
- package/dist/shared/operator/capabilities.js +26 -2
- package/dist/task/source-prepare/prepare.js +49 -2
- package/dist/worker/console/interview/grill-me.js +7 -6
- package/dist/worker/console/operator-actions.js +231 -22
- package/dist/worker/console/prd-intake-bridge.js +393 -0
- package/dist/worker/console/recovery-cta.js +35 -6
- package/dist/worker/console/static/assets/index-BpuHmlSP.js +29 -0
- package/dist/worker/console/static/index.html +1 -1
- package/dist/worker/console/static-src/app/console-types.js +1 -0
- package/dist/worker/console/static-src/app/usePrdImport.js +2 -1
- package/dist/worker/console/static-src/app/useRecoveryActions.js +24 -1
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +19 -1
- package/dist/worker/console/static-src/app/useTaskWizard.js +57 -2
- package/dist/worker/observability/read-model.js +3 -0
- package/dist/workflows/dag/failure-category.js +3 -0
- package/dist/workflows/dag/init-hybrid.js +15 -9
- package/dist/workflows/dag/node-execution.js +3 -2
- package/dist/workflows/dag/retry-policy.js +44 -11
- package/dist/workflows/dag/runner.js +6 -0
- package/dist/workflows/dag/scheduler.js +49 -1
- package/dist/workflows/dag/validate.js +16 -2
- package/docs/templates/agent-dag.schema.json +4 -4
- package/package.json +1 -1
- package/dist/worker/console/static/assets/index-CMHovlqG.js +0 -32
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<link rel="stylesheet" href="/inspect/operator-chrome.css" />
|
|
8
8
|
<title>Loop 操作台 · Operator Console</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-BpuHmlSP.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-BQkhJpV8.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
@@ -107,6 +107,7 @@ export async function postOperator(action, actionParams = {}, clientRequestId) {
|
|
|
107
107
|
export const LONG_RUNNING_ACTIONS = new Set([
|
|
108
108
|
"runDag",
|
|
109
109
|
"dagRerun",
|
|
110
|
+
"dagReconcileRun",
|
|
110
111
|
"standaloneTaskRerun",
|
|
111
112
|
]);
|
|
112
113
|
export function formatDagRerunIneligible(plan) {
|
|
@@ -3,7 +3,8 @@ import { composePrdDocuments, importPrdFiles, validatePrdFileList, } from "../pr
|
|
|
3
3
|
/** PRD multi-file import: drag/drop + selection, primary-volume selection,
|
|
4
4
|
* reorder/remove and composed-text regeneration with dirty tracking. */
|
|
5
5
|
export function usePrdImport() {
|
|
6
|
-
|
|
6
|
+
// Empty placeholder — do not prefill Console Happy Path sample text (design 2026-08-11).
|
|
7
|
+
const [prdText, setPrdText] = useState("");
|
|
7
8
|
const [prdDocuments, setPrdDocuments] = useState([]);
|
|
8
9
|
const [prdPrimaryIndex, setPrdPrimaryIndex] = useState(0);
|
|
9
10
|
const [prdComposedDirty, setPrdComposedDirty] = useState(false);
|
|
@@ -234,7 +234,30 @@ export function useRecoveryActions(params) {
|
|
|
234
234
|
setOpError("对账恢复需要 DAG 运行 ID");
|
|
235
235
|
return;
|
|
236
236
|
}
|
|
237
|
-
|
|
237
|
+
// Default supersede: orphan/interrupted runs should not be resumed.
|
|
238
|
+
// The browser Human Gate binds the destructive action + reason; the CLI
|
|
239
|
+
// still fail-closes when runner is not proven dead/stopped.
|
|
240
|
+
const actionParams = {
|
|
241
|
+
runId,
|
|
242
|
+
reason,
|
|
243
|
+
reconcileAction: "supersede",
|
|
244
|
+
};
|
|
245
|
+
const prep = await runAction("prepareMutationGate", {
|
|
246
|
+
action: "dagReconcileRun",
|
|
247
|
+
actionParams,
|
|
248
|
+
});
|
|
249
|
+
if (!isActionSuccess(prep))
|
|
250
|
+
return;
|
|
251
|
+
const receipt = (prep?.result ?? prep);
|
|
252
|
+
if (!receipt.receiptId || !receipt.humanGateToken) {
|
|
253
|
+
setOpError("prepareMutationGate 未返回可消费的 Human Gate receipt");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
await runAction("dagReconcileRun", {
|
|
257
|
+
...actionParams,
|
|
258
|
+
confirmationId: receipt.receiptId,
|
|
259
|
+
humanGateToken: receipt.humanGateToken,
|
|
260
|
+
}, { clientRequestId });
|
|
238
261
|
return;
|
|
239
262
|
}
|
|
240
263
|
setOpMessage(`${cta.label}:请经 CLI — ${cta.commandHint ?? ""}`);
|
|
@@ -21,6 +21,7 @@ export function useRecoveryConsole(params) {
|
|
|
21
21
|
const [recoveryRunsLoading, setRecoveryRunsLoading] = useState(false);
|
|
22
22
|
const [recoveryNodesLoading, setRecoveryNodesLoading] = useState(false);
|
|
23
23
|
const [recoveryRunStatus, setRecoveryRunStatus] = useState(null);
|
|
24
|
+
const [recoveryFailureCategory, setRecoveryFailureCategory] = useState(null);
|
|
24
25
|
const [manualRunId, setManualRunId] = useState(false);
|
|
25
26
|
const [manualNodeId, setManualNodeId] = useState(false);
|
|
26
27
|
const [recoveryReport, setRecoveryReport] = useState(null);
|
|
@@ -122,6 +123,7 @@ export function useRecoveryConsole(params) {
|
|
|
122
123
|
if (!id) {
|
|
123
124
|
setRecoveryNodes([]);
|
|
124
125
|
setRecoveryRunStatus(null);
|
|
126
|
+
setRecoveryFailureCategory(null);
|
|
125
127
|
return;
|
|
126
128
|
}
|
|
127
129
|
setRecoveryNodesLoading(true);
|
|
@@ -132,10 +134,16 @@ export function useRecoveryConsole(params) {
|
|
|
132
134
|
if (!res.ok) {
|
|
133
135
|
setRecoveryNodes([]);
|
|
134
136
|
setRecoveryRunStatus(null);
|
|
137
|
+
setRecoveryFailureCategory(null);
|
|
135
138
|
return;
|
|
136
139
|
}
|
|
137
140
|
const body = (await res.json());
|
|
138
|
-
|
|
141
|
+
// Prefer adaptive effectiveStatus (running-suspected-stall / interrupted)
|
|
142
|
+
// over raw state.status so Recovery CTAs match doctor/liveness facts.
|
|
143
|
+
const status = (typeof body.effectiveStatus === "string" && body.effectiveStatus) ||
|
|
144
|
+
(typeof body.status === "string" && body.status) ||
|
|
145
|
+
null;
|
|
146
|
+
setRecoveryRunStatus(status);
|
|
139
147
|
const rawNodes = Array.isArray(body.nodes) ? body.nodes : [];
|
|
140
148
|
const nodes = sortRecoveryNodeCandidates(rawNodes
|
|
141
149
|
.map((n) => {
|
|
@@ -151,10 +159,19 @@ export function useRecoveryConsole(params) {
|
|
|
151
159
|
})
|
|
152
160
|
.filter((n) => n !== null));
|
|
153
161
|
setRecoveryNodes(nodes);
|
|
162
|
+
// Surface controller-interrupted (or other) from node records when run-level
|
|
163
|
+
// failureCategory is absent — needed for S6 interrupt CTA classification.
|
|
164
|
+
const runFailure = (typeof body.failureCategory === "string" && body.failureCategory) ||
|
|
165
|
+
undefined;
|
|
166
|
+
const nodeFailure = rawNodes.find((n) => typeof n.failureCategory === "string" &&
|
|
167
|
+
n.failureCategory &&
|
|
168
|
+
n.failureCategory !== "success")?.failureCategory;
|
|
169
|
+
setRecoveryFailureCategory(runFailure ?? nodeFailure ?? null);
|
|
154
170
|
}
|
|
155
171
|
catch {
|
|
156
172
|
setRecoveryNodes([]);
|
|
157
173
|
setRecoveryRunStatus(null);
|
|
174
|
+
setRecoveryFailureCategory(null);
|
|
158
175
|
}
|
|
159
176
|
finally {
|
|
160
177
|
setRecoveryNodesLoading(false);
|
|
@@ -210,6 +227,7 @@ export function useRecoveryConsole(params) {
|
|
|
210
227
|
operationState: recoveryRunStatus ? null : lastOpState,
|
|
211
228
|
errorCode: recoveryRunStatus ? null : opErrorCode,
|
|
212
229
|
dagStatus: recoveryRunStatus,
|
|
230
|
+
failureCategory: recoveryFailureCategory,
|
|
213
231
|
});
|
|
214
232
|
const recoveryCtas = useMemo(() => consoleRecoveryCtas(recoveryFact), [recoveryFact]);
|
|
215
233
|
const recoveryLanes = useMemo(() => partitionRecoveryCtas(recoveryCtas), [recoveryCtas]);
|
|
@@ -10,6 +10,9 @@ export function useTaskWizard(params) {
|
|
|
10
10
|
const [taskId, setTaskId] = useState("");
|
|
11
11
|
const [taskTitle, setTaskTitle] = useState("");
|
|
12
12
|
const [taskKind, setTaskKind] = useState("standard");
|
|
13
|
+
const [allowedPathsText, setAllowedPathsText] = useState("");
|
|
14
|
+
const [forbiddenPathsText, setForbiddenPathsText] = useState("");
|
|
15
|
+
const [verifyCommandsText, setVerifyCommandsText] = useState("");
|
|
13
16
|
const workflowMeta = WORKFLOW_OPTIONS.find((w) => w.kind === taskKind) ?? WORKFLOW_OPTIONS[0];
|
|
14
17
|
const [sessionId, setSessionId] = useState(null);
|
|
15
18
|
const [question, setQuestion] = useState(null);
|
|
@@ -166,14 +169,26 @@ export function useTaskWizard(params) {
|
|
|
166
169
|
return;
|
|
167
170
|
void runContractPreflight(false);
|
|
168
171
|
}, [step, taskId, assessmentId, runContractPreflight]);
|
|
172
|
+
const splitBoundaryList = useCallback((text) => {
|
|
173
|
+
return text
|
|
174
|
+
.split(/[\n,;,;]+/)
|
|
175
|
+
.map((s) => s.trim())
|
|
176
|
+
.filter(Boolean);
|
|
177
|
+
}, []);
|
|
169
178
|
const importPrdAndCreate = useCallback(async () => {
|
|
170
179
|
const docsPayload = prd.prdDocuments.length > 0
|
|
171
180
|
? ensureUniqueDocumentNames(prd.prdDocuments)
|
|
172
181
|
: undefined;
|
|
182
|
+
const allowedPaths = splitBoundaryList(allowedPathsText);
|
|
183
|
+
const forbiddenPaths = splitBoundaryList(forbiddenPathsText);
|
|
184
|
+
const verifyCommands = splitBoundaryList(verifyCommandsText);
|
|
173
185
|
const actionParams = {
|
|
174
186
|
content: prd.prdText,
|
|
175
187
|
taskKind,
|
|
176
188
|
...(docsPayload ? { documents: docsPayload } : {}),
|
|
189
|
+
...(allowedPaths.length > 0 ? { allowedPaths } : {}),
|
|
190
|
+
...(forbiddenPaths.length > 0 ? { forbiddenPaths } : {}),
|
|
191
|
+
...(verifyCommands.length > 0 ? { verifyCommands } : {}),
|
|
177
192
|
};
|
|
178
193
|
if (!operatorRequestFits("bootstrapFromPrd", actionParams)) {
|
|
179
194
|
prd.setPrdFileError(operatorRequestTooLargeMessage());
|
|
@@ -187,9 +202,33 @@ export function useTaskWizard(params) {
|
|
|
187
202
|
setTaskId(result.taskId);
|
|
188
203
|
if (result.title)
|
|
189
204
|
setTaskTitle(result.title);
|
|
190
|
-
|
|
205
|
+
const parseLabel = result.parseStatus ?? "pending";
|
|
206
|
+
const objectiveHint = result.draftPreview?.objective?.trim();
|
|
207
|
+
const gapCount = Array.isArray(result.gaps) ? result.gaps.length : 0;
|
|
208
|
+
const semanticNote = result.semanticIntake?.applied
|
|
209
|
+
? "semantic"
|
|
210
|
+
: (result.semanticIntake?.code ?? "no-semantic");
|
|
211
|
+
setOpMessage([
|
|
212
|
+
`已创建任务「${result.title ?? ""}」(${result.taskId ?? ""})`,
|
|
213
|
+
`来源:${result.identitySource ?? "prd"}`,
|
|
214
|
+
`解析:${parseLabel}(${semanticNote})`,
|
|
215
|
+
objectiveHint ? `目标:${objectiveHint.slice(0, 120)}` : null,
|
|
216
|
+
gapCount > 0 ? `缺口 ${gapCount} 项待确认` : null,
|
|
217
|
+
]
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
.join(" · "));
|
|
191
220
|
setStep("interview");
|
|
192
|
-
}, [
|
|
221
|
+
}, [
|
|
222
|
+
allowedPathsText,
|
|
223
|
+
forbiddenPathsText,
|
|
224
|
+
prd,
|
|
225
|
+
runAction,
|
|
226
|
+
setOpMessage,
|
|
227
|
+
setStep,
|
|
228
|
+
splitBoundaryList,
|
|
229
|
+
taskKind,
|
|
230
|
+
verifyCommandsText,
|
|
231
|
+
]);
|
|
193
232
|
const wizardSteps = [
|
|
194
233
|
{ id: "setup", label: "就绪检查" },
|
|
195
234
|
{ id: "import-prd", label: "导入需求" },
|
|
@@ -205,6 +244,22 @@ export function useTaskWizard(params) {
|
|
|
205
244
|
taskTitle,
|
|
206
245
|
taskKind,
|
|
207
246
|
setTaskKind,
|
|
247
|
+
engineering: {
|
|
248
|
+
allowedPathsText,
|
|
249
|
+
forbiddenPathsText,
|
|
250
|
+
verifyCommandsText,
|
|
251
|
+
},
|
|
252
|
+
setEngineering: (patch) => {
|
|
253
|
+
if (patch.allowedPathsText !== undefined) {
|
|
254
|
+
setAllowedPathsText(patch.allowedPathsText);
|
|
255
|
+
}
|
|
256
|
+
if (patch.forbiddenPathsText !== undefined) {
|
|
257
|
+
setForbiddenPathsText(patch.forbiddenPathsText);
|
|
258
|
+
}
|
|
259
|
+
if (patch.verifyCommandsText !== undefined) {
|
|
260
|
+
setVerifyCommandsText(patch.verifyCommandsText);
|
|
261
|
+
}
|
|
262
|
+
},
|
|
208
263
|
workflowMeta,
|
|
209
264
|
sessionId,
|
|
210
265
|
question,
|
|
@@ -1488,6 +1488,7 @@ function mergeDagRun(existing, incoming) {
|
|
|
1488
1488
|
executionMode: incoming.executionMode ?? existing.executionMode,
|
|
1489
1489
|
liveness: incoming.liveness ?? existing.liveness,
|
|
1490
1490
|
effectiveStatus: incoming.effectiveStatus ?? existing.effectiveStatus,
|
|
1491
|
+
failureCategory: incoming.failureCategory ?? existing.failureCategory,
|
|
1491
1492
|
stateConsistent: incoming.stateConsistent ?? existing.stateConsistent,
|
|
1492
1493
|
recoveryEligibility: incoming.recoveryEligibility ?? existing.recoveryEligibility,
|
|
1493
1494
|
title: incoming.title ?? existing.title,
|
|
@@ -1893,6 +1894,7 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1893
1894
|
: {}),
|
|
1894
1895
|
}
|
|
1895
1896
|
: undefined;
|
|
1897
|
+
const failureCategory = readString(parsed, "failureCategory");
|
|
1896
1898
|
return {
|
|
1897
1899
|
dagRunId,
|
|
1898
1900
|
status,
|
|
@@ -1900,6 +1902,7 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1900
1902
|
...(executionMode ? { executionMode } : {}),
|
|
1901
1903
|
liveness: liveness.status,
|
|
1902
1904
|
effectiveStatus,
|
|
1905
|
+
...(failureCategory ? { failureCategory } : {}),
|
|
1903
1906
|
stateConsistent,
|
|
1904
1907
|
...(recoveryEligibility ? { recoveryEligibility } : {}),
|
|
1905
1908
|
...(title ? { title } : {}),
|
|
@@ -19,6 +19,9 @@ const RAW_TO_NORMALIZED = {
|
|
|
19
19
|
success: "success",
|
|
20
20
|
"write-guard": "write-guard",
|
|
21
21
|
timeout: "timeout",
|
|
22
|
+
// Clean implementer stall with zero writes — still a timeout class for report,
|
|
23
|
+
// but retryable under WRITER_TRANSPORT_RETRY_POLICY.
|
|
24
|
+
"writer-clean-timeout": "timeout",
|
|
22
25
|
"missing-api-key": "auth",
|
|
23
26
|
auth: "auth",
|
|
24
27
|
"human-rejected": "human-rejected",
|
|
@@ -9,7 +9,7 @@ import { planMavenVerification, } from "../../verification/maven/index.js";
|
|
|
9
9
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
10
10
|
import { BASELINE_FORBIDDEN_PATHS } from "./governance-constants.js";
|
|
11
11
|
import { buildDecisionEnvelopePromptContract } from "./decision-envelope.js";
|
|
12
|
-
import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, STRUCTURED_REQUIRED_PI_RETRY_POLICY, BACKEND_TEST_WRITER_COMPLETENESS_RETRY_POLICY, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
|
|
12
|
+
import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, STRUCTURED_REQUIRED_PI_RETRY_POLICY, BACKEND_TEST_WRITER_COMPLETENESS_RETRY_POLICY, WRITER_TRANSPORT_RETRY_POLICY, isSafeReadOnlyPiRetryCandidate, isWriterTransportRetryCandidate, } from "./retry-policy.js";
|
|
13
13
|
import { REVIEW_JSON_VERDICT_OUTPUT_PROTOCOL, REVIEW_VERDICT_OUTPUT_PROTOCOL, } from "./output-protocol.js";
|
|
14
14
|
import { resolveAdapter } from "../../adapters/index.js";
|
|
15
15
|
import { loadHarnessManifest } from "../../governance/harness.js";
|
|
@@ -4174,7 +4174,7 @@ function buildFrontendTestHybridDag(sources) {
|
|
|
4174
4174
|
commands: [
|
|
4175
4175
|
[
|
|
4176
4176
|
"node -e",
|
|
4177
|
-
JSON.stringify(`const fs=require('fs');const {spawnSync}=require('child_process');const p='testcase/frontend/rag/context.md';const probePath='testcase/frontend/rag/environment-probe.json';function writeProbe(obj){try{fs.mkdirSync('testcase/frontend/rag',{recursive:true});fs.writeFileSync(probePath,JSON.stringify(obj,null,2)+'\\n');let ctx=fs.existsSync(p)?fs.readFileSync(p,'utf8'):'';const line='environmentProbe: '+obj.status+(obj.blockedReason?(' ('+obj.blockedReason+')'):'');if(/environmentProbe\\s*[:=]/i.test(ctx)){ctx=ctx.replace(/environmentProbe\\s*[:=]\\s*.*/i,line);}else{ctx=ctx.trimEnd()+'\\n\\n'+line+'\\n';}fs.writeFileSync(p,ctx);}catch(e){console.error('probe-write-failed',e&&e.message||e);}}function redactUrl(u){try{const x=new URL(u);x.username='';x.password='';if(x.search){x.search='';}return x.toString();}catch(_){return String(u).replace(/\\/\\/[^@\\s]+@/g,'//');}}function failBlocked(reason,extra){const payload=Object.assign({status:'unreachable',blockedReason:reason,baseUrlRedacted:extra&&extra.baseUrlRedacted||null,httpStatus:extra&&extra.httpStatus||null,method:extra&&extra.method||null,curlExit:extra&&extra.curlExit||null,errorClass:extra&&extra.errorClass||null},extra||{});writeProbe(payload);console.error('frontend-test preflight blocked: '+JSON.stringify({blockedReason:reason,baseUrl:payload.baseUrlRedacted,httpStatus:payload.httpStatus,errorClass:payload.errorClass}));throw new Error('frontend-test preflight blocked: '+reason);}if(!fs.existsSync(p))throw new Error('missing '+p);const s=fs.readFileSync(p,'utf8');const patterns=[/baseUrl\\s*[:=]\\s*[
|
|
4177
|
+
JSON.stringify(`const fs=require('fs');const {spawnSync}=require('child_process');const p='testcase/frontend/rag/context.md';const probePath='testcase/frontend/rag/environment-probe.json';function writeProbe(obj){try{fs.mkdirSync('testcase/frontend/rag',{recursive:true});fs.writeFileSync(probePath,JSON.stringify(obj,null,2)+'\\n');let ctx=fs.existsSync(p)?fs.readFileSync(p,'utf8'):'';const line='environmentProbe: '+obj.status+(obj.blockedReason?(' ('+obj.blockedReason+')'):'');if(/environmentProbe\\s*[:=]/i.test(ctx)){ctx=ctx.replace(/environmentProbe\\s*[:=]\\s*.*/i,line);}else{ctx=ctx.trimEnd()+'\\n\\n'+line+'\\n';}fs.writeFileSync(p,ctx);}catch(e){console.error('probe-write-failed',e&&e.message||e);}}function redactUrl(u){try{const x=new URL(u);x.username='';x.password='';if(x.search){x.search='';}return x.toString();}catch(_){return String(u).replace(/\\/\\/[^@\\s]+@/g,'//');}}function failBlocked(reason,extra){const payload=Object.assign({status:'unreachable',blockedReason:reason,baseUrlRedacted:extra&&extra.baseUrlRedacted||null,httpStatus:extra&&extra.httpStatus||null,method:extra&&extra.method||null,curlExit:extra&&extra.curlExit||null,errorClass:extra&&extra.errorClass||null},extra||{});writeProbe(payload);console.error('frontend-test preflight blocked: '+JSON.stringify({blockedReason:reason,baseUrl:payload.baseUrlRedacted,httpStatus:payload.httpStatus,errorClass:payload.errorClass}));throw new Error('frontend-test preflight blocked: '+reason);}if(!fs.existsSync(p))throw new Error('missing '+p);const s=fs.readFileSync(p,'utf8');const patterns=[/baseUrl\\s*[:=]\\s*["'\\x60]?((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/base[-_ ]url\\s*[:=]\\s*["'\\x60]?((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/playwright-cli open --browser=chrome\\s+((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/(https?:\\/\\/(?:localhost|127\\.0\\.0\\.1)[^\\s)\\}\\],"']*)/i];let baseUrl=null;for(const re of patterns){const m=s.match(re);if(m){baseUrl=m[1];break;}}if(!baseUrl)throw new Error('frontend-test preflight missing absolute baseUrl from context.md');baseUrl=baseUrl.replace(/[)\\}\\],."'\\x60]+$/,'');const sourceMatch=s.match(/baseUrlSource\\s*[:=]\\s*([^\\r\\n]+)/i);const baseUrlSource=sourceMatch?sourceMatch[1].trim():'context.md';let parsed;try{parsed=new URL(baseUrl);}catch(_){throw new Error('baseUrl must be absolute http(s): '+baseUrl);}if((parsed.protocol!=='http:'&&parsed.protocol!=='https:')||parsed.username||parsed.password||parsed.search||parsed.hash)throw new Error('unsafe baseUrl from context.md: '+redactUrl(baseUrl));if(/(?:^|\\.)(?:www\\.)?[^.]*(?:prod|production)/i.test(parsed.hostname))throw new Error('production URL forbidden: '+redactUrl(baseUrl));baseUrl=parsed.toString();const safe=redactUrl(baseUrl);const curlCheck=spawnSync('curl',['--version'],{encoding:'utf8'});if(curlCheck.error||curlCheck.status!==0){failBlocked('curl-unavailable',{baseUrlRedacted:safe,errorClass:'curl-missing'});}function probe(method){const args=['-sS','-o','/dev/null','-w','%{http_code}','--connect-timeout','3','--max-time','8','-X',method,'-L','--max-redirs','3','--http1.1','--proto-redir','=http,https',safe];const r=spawnSync('curl',args,{encoding:'utf8'});return r;}let used='HEAD';let r=probe('HEAD');let code=String(r.stdout||'').trim();let statusNum=parseInt(code,10);const headRejected=r.status!==0||!statusNum||statusNum===405||statusNum===501;if(headRejected){used='GET';r=probe('GET');code=String(r.stdout||'').trim();statusNum=parseInt(code,10);}const ok=statusNum>=200&&statusNum<400;if(!ok){const errClass=r.error?'spawn-error':(r.status!==0?'curl-exit-'+r.status:('http-'+statusNum));failBlocked('frontend-base-url-unreachable',{baseUrlRedacted:safe,httpStatus:statusNum||null,method:used,curlExit:r.status,errorClass:errClass});}writeProbe({status:'reachable',blockedReason:null,baseUrl:safe,baseUrlRedacted:safe,baseUrlSource,httpStatus:statusNum,method:used,curlExit:r.status});console.log('frontend-test-execution-v1 validated contextBaseUrl='+safe+' source='+baseUrlSource+' probe=reachable method='+used+' httpStatus='+statusNum);`),
|
|
4178
4178
|
].join(" "),
|
|
4179
4179
|
],
|
|
4180
4180
|
cwd: ".",
|
|
@@ -5654,20 +5654,26 @@ function cloneTask(task, patch = {}) {
|
|
|
5654
5654
|
return { ...task, ...patch };
|
|
5655
5655
|
}
|
|
5656
5656
|
/**
|
|
5657
|
-
* Apply
|
|
5658
|
-
* verifier/supervisor/closeout
|
|
5659
|
-
*
|
|
5657
|
+
* Apply default Pi retry policies to generated DAG nodes:
|
|
5658
|
+
* - safe read-only planner/scout/reviewer/verifier/supervisor/closeout
|
|
5659
|
+
* - exclusive implementers get a single clean-timeout transport retry
|
|
5660
|
+
* (provider stall with zero attributed writes only)
|
|
5661
|
+
* Dynamic, shell, static, and decision-gate nodes are skipped. Idempotent:
|
|
5660
5662
|
* never overwrites an explicit retryPolicy a task already declares.
|
|
5661
5663
|
*/
|
|
5662
5664
|
function applyDefaultReadOnlyRetryPolicy(spec) {
|
|
5663
5665
|
for (const task of spec.tasks) {
|
|
5664
5666
|
if (task.retryPolicy !== undefined)
|
|
5665
5667
|
continue;
|
|
5666
|
-
if (
|
|
5668
|
+
if (isSafeReadOnlyPiRetryCandidate(task)) {
|
|
5669
|
+
task.retryPolicy = task.outputProtocol
|
|
5670
|
+
? PROTOCOL_AWARE_PI_RETRY_POLICY
|
|
5671
|
+
: DEFAULT_READ_ONLY_PI_RETRY_POLICY;
|
|
5667
5672
|
continue;
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5673
|
+
}
|
|
5674
|
+
if (isWriterTransportRetryCandidate(task)) {
|
|
5675
|
+
task.retryPolicy = WRITER_TRANSPORT_RETRY_POLICY;
|
|
5676
|
+
}
|
|
5671
5677
|
}
|
|
5672
5678
|
}
|
|
5673
5679
|
function getTaskOrThrow(spec, id) {
|
|
@@ -9,7 +9,7 @@ import { resolveContextPolicy } from "./context-policy.js";
|
|
|
9
9
|
import { buildDagNodePromptEnvelope, formatConvergenceFeedbackBlock, } from "./prompt.js";
|
|
10
10
|
import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
|
|
11
11
|
import { buildOutputLimitRecoverySection, loadBackendTestWriterProgressForRetry, } from "./backend-test-writer-completeness.js";
|
|
12
|
-
import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, isWriterEmptyDiffRetryCandidate, } from "./retry-policy.js";
|
|
12
|
+
import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, } from "./retry-policy.js";
|
|
13
13
|
import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
|
|
14
14
|
import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, parseJsonReviewVerdict, validateOutputProtocol, } from "./output-protocol.js";
|
|
15
15
|
import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
@@ -577,7 +577,8 @@ export async function executeDagNode(input) {
|
|
|
577
577
|
}
|
|
578
578
|
const retryPolicy = task.retryPolicy &&
|
|
579
579
|
(isSafeReadOnlyPiRetryCandidate(task) ||
|
|
580
|
-
isWriterEmptyDiffRetryCandidate(task)
|
|
580
|
+
isWriterEmptyDiffRetryCandidate(task) ||
|
|
581
|
+
isWriterTransportRetryCandidate(task))
|
|
581
582
|
? task.retryPolicy
|
|
582
583
|
: undefined;
|
|
583
584
|
const maxAttempts = retryPolicy?.maxAttempts ?? 1;
|
|
@@ -25,6 +25,13 @@ export const STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY = "invalid-output";
|
|
|
25
25
|
export const WRITER_EMPTY_DIFF_RETRY_CATEGORY = "writer-empty-diff";
|
|
26
26
|
/** Retry when a backend-test writer finished but Completeness Gate found missing/broken targets. */
|
|
27
27
|
export const INCOMPLETE_WRITE_SET_RETRY_CATEGORY = "incomplete-write-set";
|
|
28
|
+
/**
|
|
29
|
+
* Provider/transport stall or absolute timeout on a Pi exclusive writer that
|
|
30
|
+
* left zero attributed workspace writes and zero write-tool calls. Safe to
|
|
31
|
+
* retry once because the attempt was a pure provider flake (no side effects).
|
|
32
|
+
* Partial writes keep the original `timeout` category and do NOT retry.
|
|
33
|
+
*/
|
|
34
|
+
export const WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY = "writer-clean-timeout";
|
|
28
35
|
export const STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES = [
|
|
29
36
|
...DEFAULT_DAG_RETRY_CATEGORIES,
|
|
30
37
|
STRUCTURED_OUTPUT_RETRY_CATEGORY,
|
|
@@ -43,6 +50,7 @@ export const ALL_DAG_RETRY_CATEGORIES = [
|
|
|
43
50
|
STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY,
|
|
44
51
|
WRITER_EMPTY_DIFF_RETRY_CATEGORY,
|
|
45
52
|
INCOMPLETE_WRITE_SET_RETRY_CATEGORY,
|
|
53
|
+
WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
|
|
46
54
|
];
|
|
47
55
|
const RETRY_SAFE_PI_ROLES = new Set([
|
|
48
56
|
"planner",
|
|
@@ -119,9 +127,9 @@ export const PROTOCOL_AWARE_PI_RETRY_POLICY = {
|
|
|
119
127
|
retryCategories: [...PROTOCOL_AWARE_DAG_RETRY_CATEGORIES],
|
|
120
128
|
};
|
|
121
129
|
/**
|
|
122
|
-
* The sole writer retry policy. It is
|
|
123
|
-
* read-only default: a writer may retry only
|
|
124
|
-
* otherwise successful attempt changed no files.
|
|
130
|
+
* The sole writer retry policy for requireChangedFiles writers. It is
|
|
131
|
+
* intentionally not included in any read-only default: a writer may retry only
|
|
132
|
+
* after its executor proves an otherwise successful attempt changed no files.
|
|
125
133
|
*/
|
|
126
134
|
export const WRITER_EMPTY_DIFF_RETRY_POLICY = {
|
|
127
135
|
maxAttempts: 2,
|
|
@@ -130,6 +138,18 @@ export const WRITER_EMPTY_DIFF_RETRY_POLICY = {
|
|
|
130
138
|
maxDelayMs: 30000,
|
|
131
139
|
retryCategories: [WRITER_EMPTY_DIFF_RETRY_CATEGORY],
|
|
132
140
|
};
|
|
141
|
+
/**
|
|
142
|
+
* Bounded transport retry for standard exclusive implementers when a provider
|
|
143
|
+
* stall/timeout left no workspace writes. maxAttempts=2 (one retry). Never
|
|
144
|
+
* retries timeout with partial writes (those stay non-retryable `timeout`).
|
|
145
|
+
*/
|
|
146
|
+
export const WRITER_TRANSPORT_RETRY_POLICY = {
|
|
147
|
+
maxAttempts: 2,
|
|
148
|
+
backoff: "exponential",
|
|
149
|
+
initialDelayMs: 5000,
|
|
150
|
+
maxDelayMs: 30000,
|
|
151
|
+
retryCategories: [WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY],
|
|
152
|
+
};
|
|
133
153
|
/**
|
|
134
154
|
* Backend-test generation writers: empty-diff once, plus bounded incomplete-write-set
|
|
135
155
|
* recovery attempts driven by Completeness Gate (missing/broken target files).
|
|
@@ -154,24 +174,37 @@ export function isRetryablePiFailureCategory(rawFailureCategory, options = {}) {
|
|
|
154
174
|
const categories = options.retryCategories ?? DEFAULT_DAG_RETRY_CATEGORIES;
|
|
155
175
|
return categories.includes(rawFailureCategory);
|
|
156
176
|
}
|
|
157
|
-
|
|
158
|
-
* Static eligibility for the sole retryable writer class. This deliberately
|
|
159
|
-
* does not infer a no-op: the Pi executor assigns writer-empty-diff only after
|
|
160
|
-
* post-write-guard attribution proves an empty changedFiles list.
|
|
161
|
-
*/
|
|
162
|
-
export function isWriterEmptyDiffRetryCandidate(task) {
|
|
177
|
+
function isExclusivePiImplementer(task) {
|
|
163
178
|
return (task.executor === "pi" &&
|
|
164
179
|
task.role === "implementer" &&
|
|
165
180
|
task.toolProfile === "write" &&
|
|
166
181
|
task.writePolicy === "exclusive" &&
|
|
167
182
|
(task.writeSet?.length ?? 0) > 0 &&
|
|
168
|
-
task.writerOutcomePolicy?.requireChangedFiles === true &&
|
|
169
183
|
!task.decisionGate?.enabled &&
|
|
170
184
|
!task.dynamicExpansion &&
|
|
171
185
|
!task.dynamicReduction &&
|
|
172
186
|
!task.dynamicCondition &&
|
|
173
187
|
!task.dynamicLoopUntil);
|
|
174
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Static eligibility for the requireChangedFiles writer-empty-diff class.
|
|
191
|
+
* The Pi executor assigns writer-empty-diff only after post-write-guard
|
|
192
|
+
* attribution proves an empty changedFiles list.
|
|
193
|
+
*/
|
|
194
|
+
export function isWriterEmptyDiffRetryCandidate(task) {
|
|
195
|
+
return (isExclusivePiImplementer(task) &&
|
|
196
|
+
task.writerOutcomePolicy?.requireChangedFiles === true);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Standard exclusive implementers (implementation-outcome writers) may take a
|
|
200
|
+
* single clean-timeout transport retry. Backend completeness writers already
|
|
201
|
+
* have their own policy and are excluded here.
|
|
202
|
+
*/
|
|
203
|
+
export function isWriterTransportRetryCandidate(task) {
|
|
204
|
+
return (isExclusivePiImplementer(task) &&
|
|
205
|
+
task.writerOutcomePolicy?.type === "implementation-outcome-v1" &&
|
|
206
|
+
task.writerOutcomePolicy.requireChangedFiles !== true);
|
|
207
|
+
}
|
|
175
208
|
export function isSafeReadOnlyPiRetryCandidate(task) {
|
|
176
209
|
if (task.executor !== "pi")
|
|
177
210
|
return false;
|
|
@@ -210,6 +243,6 @@ export function computeBackoffDelayMs(attemptNumber, policy) {
|
|
|
210
243
|
return Math.min(policy.initialDelayMs, policy.maxDelayMs);
|
|
211
244
|
}
|
|
212
245
|
const exponent = attemptNumber - 2;
|
|
213
|
-
const raw = policy.initialDelayMs *
|
|
246
|
+
const raw = policy.initialDelayMs * 2 ** exponent;
|
|
214
247
|
return Math.min(raw, policy.maxDelayMs);
|
|
215
248
|
}
|
|
@@ -278,6 +278,7 @@ export async function runDag(spec, opts) {
|
|
|
278
278
|
maxConcurrent,
|
|
279
279
|
executeNode: opts.executeNode,
|
|
280
280
|
observer: opts.observer,
|
|
281
|
+
abortSignal: opts.abortSignal,
|
|
281
282
|
activeRunDir,
|
|
282
283
|
completedRunDir,
|
|
283
284
|
pausedRunDir,
|
|
@@ -309,6 +310,7 @@ export async function runDagContinuation(opts) {
|
|
|
309
310
|
maxConcurrent,
|
|
310
311
|
executeNode: opts.executeNode,
|
|
311
312
|
observer: opts.observer,
|
|
313
|
+
abortSignal: opts.abortSignal,
|
|
312
314
|
activeRunDir,
|
|
313
315
|
completedRunDir,
|
|
314
316
|
pausedRunDir,
|
|
@@ -403,6 +405,7 @@ export async function resumeDagRun(opts) {
|
|
|
403
405
|
maxConcurrent,
|
|
404
406
|
executeNode: opts.executeNode,
|
|
405
407
|
observer: opts.observer,
|
|
408
|
+
// Resume path intentionally has no abortSignal field on ResumeDagRunOptions yet.
|
|
406
409
|
activeRunDir,
|
|
407
410
|
completedRunDir,
|
|
408
411
|
pausedRunDir,
|
|
@@ -501,6 +504,7 @@ async function executeDagCheckpoint(input) {
|
|
|
501
504
|
tasksById,
|
|
502
505
|
maxConcurrent,
|
|
503
506
|
persistState,
|
|
507
|
+
abortSignal: input.abortSignal,
|
|
504
508
|
createExecuteNodeForRank: (rankWriterNodeIds) => buildRankAwareExecuteNode({
|
|
505
509
|
baseExecuteNode,
|
|
506
510
|
customExecuteNode: input.executeNode,
|
|
@@ -509,6 +513,8 @@ async function executeDagCheckpoint(input) {
|
|
|
509
513
|
meta: { runDir, runId: state.runId, spec },
|
|
510
514
|
}),
|
|
511
515
|
executeScheduledNode: async (nodeId, executeNode, onPause) => {
|
|
516
|
+
if (input.abortSignal?.aborted)
|
|
517
|
+
return;
|
|
512
518
|
if (isHardBudgetBreached(state.budgetLedger))
|
|
513
519
|
return;
|
|
514
520
|
const preBreach = preflightBudgetOrBreach(state);
|
|
@@ -104,9 +104,35 @@ async function mapConcurrent(items, limit, fn) {
|
|
|
104
104
|
}
|
|
105
105
|
await Promise.all(executing);
|
|
106
106
|
}
|
|
107
|
+
/** Fail-closed: leave no PENDING nodes that look "still scheduled" after abort. */
|
|
108
|
+
export function markPendingNodesControllerInterrupted(state, reason = "run aborted by controller (abortSignal)") {
|
|
109
|
+
const affected = [];
|
|
110
|
+
const finishedAt = new Date().toISOString();
|
|
111
|
+
for (const [nodeId, node] of Object.entries(state.nodes)) {
|
|
112
|
+
if (node.status !== "PENDING")
|
|
113
|
+
continue;
|
|
114
|
+
node.status = "ERROR";
|
|
115
|
+
node.failureCategory = "controller-interrupted";
|
|
116
|
+
node.stderr = reason;
|
|
117
|
+
node.finishedAt = finishedAt;
|
|
118
|
+
affected.push(nodeId);
|
|
119
|
+
}
|
|
120
|
+
if (affected.length > 0 && !state.failureCategory) {
|
|
121
|
+
state.failureCategory = "controller-interrupted";
|
|
122
|
+
}
|
|
123
|
+
return affected;
|
|
124
|
+
}
|
|
107
125
|
export async function executeDagRanksOnce(input) {
|
|
108
126
|
let pausedByNodeId;
|
|
109
127
|
for (const rank of input.ranks) {
|
|
128
|
+
if (input.abortSignal?.aborted) {
|
|
129
|
+
const marked = markPendingNodesControllerInterrupted(input.state, input.abortSignal.reason
|
|
130
|
+
? `run aborted by controller: ${String(input.abortSignal.reason)}`
|
|
131
|
+
: undefined);
|
|
132
|
+
if (marked.length > 0)
|
|
133
|
+
await input.persistState();
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
110
136
|
const pending = rank.filter((id) => {
|
|
111
137
|
const node = input.state.nodes[id];
|
|
112
138
|
return node?.status === "PENDING";
|
|
@@ -169,6 +195,12 @@ export async function executeDagRanksOnce(input) {
|
|
|
169
195
|
});
|
|
170
196
|
const executeNode = input.createExecuteNodeForRank(rankWriterNodeIds);
|
|
171
197
|
for (const nodeId of pauseGateRunnable) {
|
|
198
|
+
if (input.abortSignal?.aborted) {
|
|
199
|
+
const marked = markPendingNodesControllerInterrupted(input.state);
|
|
200
|
+
if (marked.length > 0)
|
|
201
|
+
await input.persistState();
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
172
204
|
await input.executeScheduledNode(nodeId, executeNode, (id, pausedAt, pauseReason) => {
|
|
173
205
|
pausedByNodeId = id;
|
|
174
206
|
input.state.pausedAt = pausedAt;
|
|
@@ -178,9 +210,19 @@ export async function executeDagRanksOnce(input) {
|
|
|
178
210
|
if (pausedByNodeId)
|
|
179
211
|
break;
|
|
180
212
|
}
|
|
181
|
-
if (pausedByNodeId)
|
|
213
|
+
if (pausedByNodeId || input.abortSignal?.aborted) {
|
|
214
|
+
if (input.abortSignal?.aborted && !pausedByNodeId) {
|
|
215
|
+
const marked = markPendingNodesControllerInterrupted(input.state);
|
|
216
|
+
if (marked.length > 0)
|
|
217
|
+
await input.persistState();
|
|
218
|
+
}
|
|
182
219
|
break;
|
|
220
|
+
}
|
|
183
221
|
await mapConcurrent(regularRunnable, input.maxConcurrent, async (nodeId) => {
|
|
222
|
+
if (input.abortSignal?.aborted) {
|
|
223
|
+
// Sibling may still be PENDING; outer loop finalizes remaining.
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
184
226
|
await input.executeScheduledNode(nodeId, executeNode, (id, pausedAt, pauseReason) => {
|
|
185
227
|
pausedByNodeId = id;
|
|
186
228
|
input.state.pausedAt = pausedAt;
|
|
@@ -190,6 +232,12 @@ export async function executeDagRanksOnce(input) {
|
|
|
190
232
|
});
|
|
191
233
|
if (pausedByNodeId)
|
|
192
234
|
break;
|
|
235
|
+
if (input.abortSignal?.aborted) {
|
|
236
|
+
const marked = markPendingNodesControllerInterrupted(input.state);
|
|
237
|
+
if (marked.length > 0)
|
|
238
|
+
await input.persistState();
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
193
241
|
}
|
|
194
242
|
return pausedByNodeId;
|
|
195
243
|
}
|
|
@@ -3,7 +3,7 @@ import { resolveShellCommands } from "../../executors/shell-executor.js";
|
|
|
3
3
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
4
4
|
import { resolveRepairTaskForGate } from "./repair-artifact.js";
|
|
5
5
|
import { topoSortToRanks } from "./topo.js";
|
|
6
|
-
import { isSafeReadOnlyPiRetryCandidate, isWriterEmptyDiffRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "./retry-policy.js";
|
|
6
|
+
import { isSafeReadOnlyPiRetryCandidate, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "./retry-policy.js";
|
|
7
7
|
import { isBackendTestCompletenessRetryCandidate } from "./backend-test-writer-completeness.js";
|
|
8
8
|
const GOVERNANCE_WARNING_TYPES = new Set([
|
|
9
9
|
"read-only-missing-artifacts-forbidden",
|
|
@@ -598,13 +598,15 @@ function validateRetryPolicyTaskConfig(task, issues) {
|
|
|
598
598
|
return;
|
|
599
599
|
const isReadOnlyCandidate = isSafeReadOnlyPiRetryCandidate(task);
|
|
600
600
|
const isWriterEmptyDiffCandidate = isWriterEmptyDiffRetryCandidate(task);
|
|
601
|
+
const isWriterTransportCandidate = isWriterTransportRetryCandidate(task);
|
|
601
602
|
const isBackendCompletenessCandidate = isBackendTestCompletenessRetryCandidate(task);
|
|
602
603
|
if (!isReadOnlyCandidate &&
|
|
603
604
|
!isWriterEmptyDiffCandidate &&
|
|
605
|
+
!isWriterTransportCandidate &&
|
|
604
606
|
!isBackendCompletenessCandidate) {
|
|
605
607
|
issues.push({
|
|
606
608
|
type: "invalid-retry-policy",
|
|
607
|
-
message: `task ${task.id} declares retryPolicy but is neither a safe read-only non-dynamic Pi node
|
|
609
|
+
message: `task ${task.id} declares retryPolicy but is neither a safe read-only non-dynamic Pi node, an exclusive bounded Pi implementer with writerOutcomePolicy.requireChangedFiles=true, nor a standard exclusive implementer transport-retry candidate`,
|
|
608
610
|
});
|
|
609
611
|
return;
|
|
610
612
|
}
|
|
@@ -635,6 +637,18 @@ function validateRetryPolicyTaskConfig(task, issues) {
|
|
|
635
637
|
type: "invalid-retry-policy",
|
|
636
638
|
message: `task ${task.id} writer retryPolicy must use exactly two total attempts and only ${WRITER_EMPTY_DIFF_RETRY_CATEGORY}`,
|
|
637
639
|
});
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (isWriterTransportCandidate) {
|
|
643
|
+
const categories = new Set(task.retryPolicy.retryCategories);
|
|
644
|
+
if (task.retryPolicy.maxAttempts !== 2 ||
|
|
645
|
+
categories.size !== 1 ||
|
|
646
|
+
!categories.has(WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY)) {
|
|
647
|
+
issues.push({
|
|
648
|
+
type: "invalid-retry-policy",
|
|
649
|
+
message: `task ${task.id} writer transport retryPolicy must use exactly two total attempts and only ${WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY}`,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
638
652
|
}
|
|
639
653
|
}
|
|
640
654
|
function validateOutputProtocolTaskConfig(task, issues) {
|