@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.
Files changed (28) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/application/task-lifecycle/advance.js +57 -7
  3. package/dist/application/task-lifecycle/plan-transitions.js +13 -21
  4. package/dist/executors/dag-pi-executor.js +18 -3
  5. package/dist/shared/operator/capabilities.js +26 -2
  6. package/dist/task/source-prepare/prepare.js +49 -2
  7. package/dist/worker/console/interview/grill-me.js +7 -6
  8. package/dist/worker/console/operator-actions.js +231 -22
  9. package/dist/worker/console/prd-intake-bridge.js +393 -0
  10. package/dist/worker/console/recovery-cta.js +35 -6
  11. package/dist/worker/console/static/assets/index-BpuHmlSP.js +29 -0
  12. package/dist/worker/console/static/index.html +1 -1
  13. package/dist/worker/console/static-src/app/console-types.js +1 -0
  14. package/dist/worker/console/static-src/app/usePrdImport.js +2 -1
  15. package/dist/worker/console/static-src/app/useRecoveryActions.js +24 -1
  16. package/dist/worker/console/static-src/app/useRecoveryConsole.js +19 -1
  17. package/dist/worker/console/static-src/app/useTaskWizard.js +57 -2
  18. package/dist/worker/observability/read-model.js +3 -0
  19. package/dist/workflows/dag/failure-category.js +3 -0
  20. package/dist/workflows/dag/init-hybrid.js +15 -9
  21. package/dist/workflows/dag/node-execution.js +3 -2
  22. package/dist/workflows/dag/retry-policy.js +44 -11
  23. package/dist/workflows/dag/runner.js +6 -0
  24. package/dist/workflows/dag/scheduler.js +49 -1
  25. package/dist/workflows/dag/validate.js +16 -2
  26. package/docs/templates/agent-dag.schema.json +4 -4
  27. package/package.json +1 -1
  28. 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-CMHovlqG.js"></script>
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
- const [prdText, setPrdText] = useState("# 需求\n\n实现 Loop Operator Console Happy Path:Interview 契约落地 DAG confirm → run。\n");
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
- setOpMessage(`${cta.label}:请经 CLI ${cta.commandHint ?? ""}`);
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
- setRecoveryRunStatus(typeof body.status === "string" ? body.status : null);
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
- setOpMessage(`已创建任务「${result.title ?? ""}」(${result.taskId ?? ""}),来源:${result.identitySource ?? "prd"}`);
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
- }, [prd, runAction, setOpMessage, setStep, taskKind]);
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*[\"'\\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);`),
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 the default read-only Pi retry policy to safe planner/scout/reviewer/
5658
- * verifier/supervisor/closeout Pi nodes in the generated DAG. Writers,
5659
- * dynamic, shell, static, and decision-gate nodes are skipped. Idempotent:
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 (!isSafeReadOnlyPiRetryCandidate(task))
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
- task.retryPolicy = task.outputProtocol
5669
- ? PROTOCOL_AWARE_PI_RETRY_POLICY
5670
- : DEFAULT_READ_ONLY_PI_RETRY_POLICY;
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 intentionally not included in any
123
- * read-only default: a writer may retry only after its executor proves an
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 * Math.pow(2, exponent);
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 nor an exclusive bounded Pi implementer with writerOutcomePolicy.requireChangedFiles=true`,
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) {