@tea-agent/loop-agent 0.44.0-next.10 → 0.44.0-next.11
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/dist/build-stamp.json +3 -3
- package/dist/executors/dag-pi/sessions/index.js +6 -0
- package/dist/executors/dag-pi/sessions/plan-batches.js +280 -0
- package/dist/executors/dag-pi/sessions/plan-prompts.js +367 -0
- package/dist/executors/dag-pi/sessions/scout-parallel.js +197 -0
- package/dist/executors/dag-pi/sessions/segmented-plan.js +1184 -0
- package/dist/executors/dag-pi/sessions/writer-evidence.js +60 -0
- package/dist/executors/dag-pi-executor.js +12 -2075
- package/dist/workflows/dag/checkpoint.js +686 -0
- package/dist/workflows/dag/hybrid/sources.js +1812 -0
- package/dist/workflows/dag/hybrid/templates/backend-test.js +1807 -0
- package/dist/workflows/dag/hybrid/templates/frontend-test.js +702 -0
- package/dist/workflows/dag/hybrid/templates/frontend.js +1529 -0
- package/dist/workflows/dag/hybrid/templates/index.js +8 -0
- package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +449 -0
- package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +495 -0
- package/dist/workflows/dag/hybrid/templates/shared.js +101 -0
- package/dist/workflows/dag/hybrid/templates/standard.js +265 -0
- package/dist/workflows/dag/hybrid/types.js +32 -0
- package/dist/workflows/dag/init-hybrid.js +42 -7162
- package/dist/workflows/dag/runner.js +11 -1178
- package/dist/workflows/dag/terminal-status.js +502 -0
- package/docs/architecture/dag-execution.md +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/** DAG runner 终态判定、前端恢复策略与恢复操作编排。含取证读取、lease 与 operation 创建;未拆成独立 policy/operation 文件。 */
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
6
|
+
import { FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT, FRONTEND_WRITER_NODE_IDS, isConditionSkippedReason, readFrontendPrewriteResult } from "./scheduler.js";
|
|
7
|
+
import { isFrontendWriterTransientPartialWrite } from "./frontend-writer-recovery.js";
|
|
8
|
+
import { classifyReviewIssueCategoryFailureOwner, readCommittedDesignRequestFact, readCommittedReviewRequestFact, writeFrontendReviewFindingsPrd } from "./frontend-review-findings.js";
|
|
9
|
+
import { computeActionPayloadHash } from "../../infrastructure/console/operation-store.js";
|
|
10
|
+
import { classifyFailureOwner, failureOwnerSchema } from "./frontend-recovery-capsule.js";
|
|
11
|
+
import { acquireDagRecoveryLease, bindDagRecoveryLeaseExecution, claimDagRecoveryLease } from "./recovery-lease.js";
|
|
12
|
+
export function buildCandidateContractInvalidResult(state, result, artifactHash) {
|
|
13
|
+
return {
|
|
14
|
+
schemaVersion: 1,
|
|
15
|
+
outcome: "candidate-contract-invalid",
|
|
16
|
+
origin: {
|
|
17
|
+
kind: "frontend-prewrite-gate",
|
|
18
|
+
parentRunId: state.runId,
|
|
19
|
+
requestId: state.runId,
|
|
20
|
+
failedNodeId: result.failureSource ?? "frontend-plan-pi",
|
|
21
|
+
},
|
|
22
|
+
failureClass: {
|
|
23
|
+
code: "candidate-contract-invalid",
|
|
24
|
+
classification: result.classification,
|
|
25
|
+
reason: result.findings.map((finding) => `${finding.code}: ${finding.message}`).join("; ") ??
|
|
26
|
+
"candidate contract invalid",
|
|
27
|
+
},
|
|
28
|
+
evidenceRefs: [
|
|
29
|
+
{
|
|
30
|
+
runId: state.runId,
|
|
31
|
+
relativePath: FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT,
|
|
32
|
+
sha256: artifactHash,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function buildWriterPartialWriteBlockedResult(state, failedNodeId, reason) {
|
|
38
|
+
return {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
outcome: "auto-recovery-blocked",
|
|
41
|
+
origin: {
|
|
42
|
+
kind: "frontend-writer",
|
|
43
|
+
parentRunId: state.runId,
|
|
44
|
+
requestId: state.runId,
|
|
45
|
+
failedNodeId,
|
|
46
|
+
},
|
|
47
|
+
failureClass: {
|
|
48
|
+
code: "writer-transient-partial-write",
|
|
49
|
+
classification: "transient",
|
|
50
|
+
reason,
|
|
51
|
+
},
|
|
52
|
+
evidenceRefs: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function buildRecoveryResultForTrigger(state, trigger, outcome) {
|
|
56
|
+
return {
|
|
57
|
+
schemaVersion: 1,
|
|
58
|
+
outcome,
|
|
59
|
+
origin: {
|
|
60
|
+
kind: trigger.failureSource === "frontend-implement-pi"
|
|
61
|
+
? "frontend-writer"
|
|
62
|
+
: "frontend-prewrite-gate",
|
|
63
|
+
parentRunId: state.runId,
|
|
64
|
+
requestId: trigger.requestId,
|
|
65
|
+
failedNodeId: trigger.failureSource,
|
|
66
|
+
},
|
|
67
|
+
evidenceRefs: [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function collectSupersededIntermediateFailures(state) {
|
|
71
|
+
const supersededIds = new Set();
|
|
72
|
+
const supersededFailures = [];
|
|
73
|
+
const soft = state.nodes["soft-verify-shell"];
|
|
74
|
+
if (!soft || soft.status !== "ERROR") {
|
|
75
|
+
return { supersededIds, supersededFailures };
|
|
76
|
+
}
|
|
77
|
+
const repair = state.nodes["repair-pi"];
|
|
78
|
+
const hard = state.nodes["hard-verify-shell"];
|
|
79
|
+
if (repair?.status !== "FINISHED" || hard?.status !== "FINISHED") {
|
|
80
|
+
return { supersededIds, supersededFailures };
|
|
81
|
+
}
|
|
82
|
+
const observesReview = Boolean(state.nodes["review-gate-shell"]);
|
|
83
|
+
const terminalReason = state.convergence?.terminalReason;
|
|
84
|
+
if (observesReview) {
|
|
85
|
+
if (terminalReason !== "review-pass") {
|
|
86
|
+
return { supersededIds, supersededFailures };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (terminalReason && terminalReason !== "hard-verify-pass") {
|
|
90
|
+
return { supersededIds, supersededFailures };
|
|
91
|
+
}
|
|
92
|
+
// Evidence: at least one successful repair pass after soft failure, with hard verify finished in terminal state.
|
|
93
|
+
const hasRepairEvidence = !state.convergence?.enabled ||
|
|
94
|
+
state.convergence.passHistory.some((pass) => pass.reason === "hard-verify-failed" ||
|
|
95
|
+
pass.reason === "review-request-revision" ||
|
|
96
|
+
pass.status === "retrying") ||
|
|
97
|
+
repair.status === "FINISHED";
|
|
98
|
+
if (!hasRepairEvidence) {
|
|
99
|
+
return { supersededIds, supersededFailures };
|
|
100
|
+
}
|
|
101
|
+
supersededIds.add("soft-verify-shell");
|
|
102
|
+
supersededFailures.push({
|
|
103
|
+
nodeId: "soft-verify-shell",
|
|
104
|
+
preservedStatus: "ERROR",
|
|
105
|
+
supersededBy: ["repair-pi", "hard-verify-shell"],
|
|
106
|
+
pass: state.convergence?.currentPass,
|
|
107
|
+
reason: "intermediate-soft-verify-covered-by-repair-and-final-hard",
|
|
108
|
+
});
|
|
109
|
+
return { supersededIds, supersededFailures };
|
|
110
|
+
}
|
|
111
|
+
export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd, frontendRecoveryQuota = 1) {
|
|
112
|
+
const repoRoot = cwd ?? path.resolve(runDir, "..", "..", "..", "..");
|
|
113
|
+
if (state.frontendShapeState?.terminalOutcome &&
|
|
114
|
+
state.frontendShapeState.transitionCapsule) {
|
|
115
|
+
state.status = "failed";
|
|
116
|
+
return { recoveryPending: false };
|
|
117
|
+
}
|
|
118
|
+
// Terminal override: a denied frontend writer must never surface as a
|
|
119
|
+
// mechanical partial_failed just because prewrite FINISHED while the writer
|
|
120
|
+
// was SKIPPED. Fail closed on blocked before aggregation.
|
|
121
|
+
let recoveryTrigger;
|
|
122
|
+
const skipFrontendRecovery = state.failureCategory === "controller-interrupted" ||
|
|
123
|
+
state.continuation?.writerTransaction !== undefined;
|
|
124
|
+
const hasFrontendWriter = Object.keys(state.nodes).some((id) => FRONTEND_WRITER_NODE_IDS.includes(id));
|
|
125
|
+
if (hasFrontendWriter && !skipFrontendRecovery) {
|
|
126
|
+
const admission = await readFrontendPrewriteResult(runDir);
|
|
127
|
+
if (admission.ok) {
|
|
128
|
+
// M8: admission uses the accepted/blocked state machine. A blocked admission whose
|
|
129
|
+
// failureSource is frontend-plan-pi is the contract-invalid analog of
|
|
130
|
+
// the old retryable-invalid path (candidate continuation may re-plan);
|
|
131
|
+
// every other blocked admission is terminal.
|
|
132
|
+
const planContractInvalid = admission.result.classification === "blocked" &&
|
|
133
|
+
admission.result.failureSource === "frontend-plan-pi";
|
|
134
|
+
// A blocked admission caused by the design review committing
|
|
135
|
+
// `request_design_changes` is a definite finding (not a protocol or
|
|
136
|
+
// environment error): the design was reviewed and rejected. It is the
|
|
137
|
+
// design-review analog of review-request-changes — not terminal, but
|
|
138
|
+
// re-enters the next plan round with the committed design findings.
|
|
139
|
+
// The failureSource may be frontend-writer-admission-shell or
|
|
140
|
+
// frontend-design-policy-shell; the committed design fact is the
|
|
141
|
+
// authoritative signal.
|
|
142
|
+
const designRequestFact = await readCommittedDesignRequestFact({
|
|
143
|
+
runDir,
|
|
144
|
+
});
|
|
145
|
+
const designRejected = admission.result.classification === "blocked" &&
|
|
146
|
+
designRequestFact !== undefined;
|
|
147
|
+
const terminalAdmissionDenial = (admission.result.classification === "blocked" ||
|
|
148
|
+
admission.result.classification === "stale") &&
|
|
149
|
+
!planContractInvalid &&
|
|
150
|
+
!designRejected;
|
|
151
|
+
if (terminalAdmissionDenial) {
|
|
152
|
+
state.status = "failed";
|
|
153
|
+
return { recoveryPending: false };
|
|
154
|
+
}
|
|
155
|
+
if (designRejected && designRequestFact) {
|
|
156
|
+
state.terminalReason = "design-request-changes";
|
|
157
|
+
try {
|
|
158
|
+
await writeFrontendReviewFindingsPrd({
|
|
159
|
+
runDir,
|
|
160
|
+
parentRunId: state.runId,
|
|
161
|
+
fact: designRequestFact,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
console.warn(`[run-dag] warning: failed to write frontend design findings PRD: ${error instanceof Error ? error.message : String(error)}`);
|
|
166
|
+
}
|
|
167
|
+
const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
|
|
168
|
+
if (continuationCount < frontendRecoveryQuota) {
|
|
169
|
+
recoveryTrigger = {
|
|
170
|
+
requestId: state.runId,
|
|
171
|
+
// The rejected design is a plan input defect. Reset from the
|
|
172
|
+
// planner so the next child can incorporate the committed
|
|
173
|
+
// findings before design review runs again.
|
|
174
|
+
failureSource: "frontend-design-review-pi",
|
|
175
|
+
failureOwner: classifyReviewIssueCategoryFailureOwner(designRequestFact.issueCategory),
|
|
176
|
+
protocolFailureReason: `frontend design review request_design_changes (issueCategory=${designRequestFact.issueCategory})`,
|
|
177
|
+
recoveryMode: "new-dag",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (planContractInvalid) {
|
|
182
|
+
const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
|
|
183
|
+
if (continuationCount >= frontendRecoveryQuota) {
|
|
184
|
+
// Continuation quota exhausted: a second contract-invalid candidate
|
|
185
|
+
// is authoritative candidate-contract-invalid → forced failed.
|
|
186
|
+
state.frontendRecoveryResult = buildCandidateContractInvalidResult(state, admission.result, admission.artifactHash);
|
|
187
|
+
state.status = "failed";
|
|
188
|
+
return { recoveryPending: false };
|
|
189
|
+
}
|
|
190
|
+
// Root continuation still available: emit a recovery trigger for the
|
|
191
|
+
// M6 operation controller. The parent no longer writes
|
|
192
|
+
// frontendRecoveryState or stays in active/ for a child CAS.
|
|
193
|
+
recoveryTrigger = {
|
|
194
|
+
requestId: state.runId,
|
|
195
|
+
failureSource: admission.result.failureSource ?? "frontend-plan-pi",
|
|
196
|
+
failureOwner: "plan",
|
|
197
|
+
protocolFailureReason: "frontend candidate contract invalid",
|
|
198
|
+
recoveryMode: "new-dag",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Phase 5: writer transient partial write → rollback + recovery trigger.
|
|
204
|
+
// Only for frontend-implementation writers with a remaining root
|
|
205
|
+
// continuation, and only when the rollback journal (captured before the
|
|
206
|
+
// provider call) restores cleanly; otherwise the run stays failed with
|
|
207
|
+
// auto-recovery-blocked.
|
|
208
|
+
if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
|
|
209
|
+
const writerNodeId = FRONTEND_WRITER_NODE_IDS.find((id) => {
|
|
210
|
+
const node = state.nodes[id];
|
|
211
|
+
return (node &&
|
|
212
|
+
node.status === "ERROR" &&
|
|
213
|
+
isFrontendWriterTransientPartialWrite({
|
|
214
|
+
failureCategory: node.failureCategory,
|
|
215
|
+
}));
|
|
216
|
+
});
|
|
217
|
+
if (writerNodeId) {
|
|
218
|
+
const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
|
|
219
|
+
if (continuationCount < frontendRecoveryQuota) {
|
|
220
|
+
const { rollbackFrontendWriter } = await import("./frontend-writer-recovery.js");
|
|
221
|
+
const rollback = await rollbackFrontendWriter({
|
|
222
|
+
cwd: repoRoot,
|
|
223
|
+
parentRunId: state.runId,
|
|
224
|
+
});
|
|
225
|
+
if (rollback.ok) {
|
|
226
|
+
recoveryTrigger = {
|
|
227
|
+
requestId: state.runId,
|
|
228
|
+
failureSource: "frontend-implement-pi",
|
|
229
|
+
failureOwner: "implementation",
|
|
230
|
+
protocolFailureReason: "frontend writer transient partial write",
|
|
231
|
+
recoveryMode: "continuation",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
state.frontendRecoveryResult = buildWriterPartialWriteBlockedResult(state, writerNodeId, rollback.reason);
|
|
236
|
+
state.status = "failed";
|
|
237
|
+
return { recoveryPending: false };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Phase 6: verification failure → automatic recovery (re-implement +
|
|
243
|
+
// re-verify). The verify shell is terminal by design so the failed run's
|
|
244
|
+
// evidence stays clean; the M6 recovery operation re-runs implement and
|
|
245
|
+
// verify in a fresh continuation, guarded by the same continuation quota,
|
|
246
|
+
// budget fold and failure fingerprint as the other recovery paths.
|
|
247
|
+
if (hasFrontendWriter && !recoveryTrigger && !skipFrontendRecovery) {
|
|
248
|
+
const verifyFailed = state.nodes["frontend-verify-shell"]?.status === "ERROR";
|
|
249
|
+
if (verifyFailed) {
|
|
250
|
+
const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
|
|
251
|
+
if (continuationCount < frontendRecoveryQuota) {
|
|
252
|
+
let recoveryOwner = classifyFailureOwner({
|
|
253
|
+
nodeId: "frontend-verify-shell",
|
|
254
|
+
rawFailureCategory: state.nodes["frontend-verify-shell"]?.failureCategory,
|
|
255
|
+
});
|
|
256
|
+
try {
|
|
257
|
+
const verifyFailure = JSON.parse(await readFile(path.join(runDir, "contracts/frontend-verify-failure.json"), "utf8"));
|
|
258
|
+
const owner = failureOwnerSchema.safeParse(verifyFailure.failureOwner);
|
|
259
|
+
if (verifyFailure.schemaVersion === 1 &&
|
|
260
|
+
verifyFailure.schemaId === "frontend-verify-failure-v1" &&
|
|
261
|
+
verifyFailure.nodeId === "frontend-verify-shell" &&
|
|
262
|
+
verifyFailure.failureCategory === state.nodes["frontend-verify-shell"]?.failureCategory &&
|
|
263
|
+
owner.success) {
|
|
264
|
+
recoveryOwner = owner.data;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
recoveryOwner = "unknown";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// The node's classified category remains the fallback; never turn
|
|
272
|
+
// unknown/environment/configuration failures into writer work.
|
|
273
|
+
}
|
|
274
|
+
if (recoveryOwner === "implementation")
|
|
275
|
+
recoveryTrigger = {
|
|
276
|
+
requestId: state.runId,
|
|
277
|
+
failureSource: "frontend-verify-shell",
|
|
278
|
+
failureOwner: recoveryOwner,
|
|
279
|
+
protocolFailureReason: "frontend verification failure",
|
|
280
|
+
recoveryMode: "continuation",
|
|
281
|
+
};
|
|
282
|
+
else
|
|
283
|
+
state.terminalReason = recoveryOwner === "environment"
|
|
284
|
+
? "frontend-verification-wait-external"
|
|
285
|
+
: `frontend-verification-${recoveryOwner}-requires-contract-review`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Phase 7: frontend review "request changes" → not passed. When the review
|
|
290
|
+
// node FINISHED but committed `request_review_changes` (typed terminal
|
|
291
|
+
// verdict), the implementation was reviewed and rejected. The run must not
|
|
292
|
+
// surface as finished: terminal aggregation below stays partial_failed, a
|
|
293
|
+
// terminal reason is recorded, the committed findings are rendered into a
|
|
294
|
+
// downstream-task PRD draft (run-owned), and a recovery trigger carries the
|
|
295
|
+
// issueCategory-derived owner so a later DAG round consumes the findings
|
|
296
|
+
// instead of repeating the previous flow.
|
|
297
|
+
const reviewRequestFact = await readCommittedReviewRequestFact({
|
|
298
|
+
runDir,
|
|
299
|
+
});
|
|
300
|
+
if (reviewRequestFact) {
|
|
301
|
+
state.terminalReason = "review-request-changes";
|
|
302
|
+
try {
|
|
303
|
+
await writeFrontendReviewFindingsPrd({
|
|
304
|
+
runDir,
|
|
305
|
+
parentRunId: state.runId,
|
|
306
|
+
fact: reviewRequestFact,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
// The PRD draft is additive; a write failure must not mask the
|
|
311
|
+
// not-passed verdict. The terminal aggregation below still applies.
|
|
312
|
+
console.warn(`[run-dag] warning: failed to write frontend review findings PRD: ${error instanceof Error ? error.message : String(error)}`);
|
|
313
|
+
}
|
|
314
|
+
if (!recoveryTrigger && !skipFrontendRecovery) {
|
|
315
|
+
const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
|
|
316
|
+
if (continuationCount < frontendRecoveryQuota) {
|
|
317
|
+
recoveryTrigger = {
|
|
318
|
+
requestId: state.runId,
|
|
319
|
+
failureSource: "frontend-review-pi",
|
|
320
|
+
failureOwner: classifyReviewIssueCategoryFailureOwner(reviewRequestFact.issueCategory),
|
|
321
|
+
protocolFailureReason: `frontend review request_review_changes (issueCategory=${reviewRequestFact.issueCategory})`,
|
|
322
|
+
recoveryMode: "new-dag",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const finishedCount = Object.values(state.nodes).filter((n) => n.status === "FINISHED").length;
|
|
328
|
+
const { supersededIds, supersededFailures } = collectSupersededIntermediateFailures(state);
|
|
329
|
+
if (state.convergence && supersededFailures.length > 0) {
|
|
330
|
+
state.convergence.supersededFailures = supersededFailures;
|
|
331
|
+
}
|
|
332
|
+
// Prefer map keys: DagNodeRecord may not always mirror the map key on `.id`.
|
|
333
|
+
const effectiveErrorCount = Object.entries(state.nodes).filter(([nodeId, n]) => n.status === "ERROR" && !supersededIds.has(nodeId)).length;
|
|
334
|
+
const supersededErrorCount = Object.entries(state.nodes).filter(([nodeId, n]) => n.status === "ERROR" && supersededIds.has(nodeId)).length;
|
|
335
|
+
const conditionSkippedCount = Object.values(state.nodes).filter((n) => n.status === "SKIPPED" && isConditionSkippedReason(n.skippedReason)).length;
|
|
336
|
+
const successTerminal = isSuccessfulConvergenceTerminal(state);
|
|
337
|
+
// Superseded intermediate ERRORs remain ERROR in history but count as settled
|
|
338
|
+
// for terminal aggregation only (they do not inflate effectiveErrorCount).
|
|
339
|
+
if (effectiveErrorCount === 0 &&
|
|
340
|
+
successTerminal &&
|
|
341
|
+
finishedCount + conditionSkippedCount + supersededErrorCount === taskCount) {
|
|
342
|
+
// Phase 7 guard: a committed `request_review_changes` means the review
|
|
343
|
+
// rejected the implementation; the run must never surface as finished
|
|
344
|
+
// even though every node technically FINISHED.
|
|
345
|
+
state.status =
|
|
346
|
+
state.terminalReason === "review-request-changes"
|
|
347
|
+
? "partial_failed"
|
|
348
|
+
: "finished";
|
|
349
|
+
}
|
|
350
|
+
else if (finishedCount > 0) {
|
|
351
|
+
state.status = "partial_failed";
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
state.status = "failed";
|
|
355
|
+
}
|
|
356
|
+
return recoveryTrigger
|
|
357
|
+
? { recoveryPending: true, recovery: recoveryTrigger }
|
|
358
|
+
: { recoveryPending: false };
|
|
359
|
+
}
|
|
360
|
+
export function isSuccessfulConvergenceTerminal(state) {
|
|
361
|
+
const reason = state.convergence?.terminalReason;
|
|
362
|
+
if (!reason)
|
|
363
|
+
return true;
|
|
364
|
+
return SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS.has(reason);
|
|
365
|
+
}
|
|
366
|
+
export async function createRecoveryOperation(input) {
|
|
367
|
+
const { cwd, runDir, spec, state, trigger } = input;
|
|
368
|
+
const { computeFrontendRecoveryPlanForSource } = await import("./frontend-recovery-plan.js");
|
|
369
|
+
let plan;
|
|
370
|
+
try {
|
|
371
|
+
plan = computeFrontendRecoveryPlanForSource({
|
|
372
|
+
spec,
|
|
373
|
+
parentRunId: state.runId,
|
|
374
|
+
requestId: trigger.requestId,
|
|
375
|
+
failureSource: trigger.failureSource,
|
|
376
|
+
failureOwner: trigger.failureOwner,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
return { kind: "blocked", reason: error instanceof Error ? error.message : String(error) };
|
|
381
|
+
}
|
|
382
|
+
const recoveryLease = await acquireDagRecoveryLease({
|
|
383
|
+
cwd,
|
|
384
|
+
parentRunId: state.runId,
|
|
385
|
+
requestId: trigger.requestId,
|
|
386
|
+
kind: "auto-frontend-recovery",
|
|
387
|
+
});
|
|
388
|
+
let effectiveLease = recoveryLease;
|
|
389
|
+
let observeOnly = false;
|
|
390
|
+
if (recoveryLease.kind === "held") {
|
|
391
|
+
if (recoveryLease.lease.kind !== "auto-frontend-recovery" ||
|
|
392
|
+
recoveryLease.lease.requestId !== trigger.requestId) {
|
|
393
|
+
return { kind: "lease-held", lease: recoveryLease.lease };
|
|
394
|
+
}
|
|
395
|
+
const claimed = await claimDagRecoveryLease({
|
|
396
|
+
leasePath: recoveryLease.leasePath,
|
|
397
|
+
lease: recoveryLease.lease,
|
|
398
|
+
});
|
|
399
|
+
effectiveLease = claimed;
|
|
400
|
+
if (claimed.kind === "held") {
|
|
401
|
+
if (!claimed.lease.executionRef) {
|
|
402
|
+
return { kind: "lease-held", lease: claimed.lease };
|
|
403
|
+
}
|
|
404
|
+
observeOnly = true;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const planHash = createHash("sha256")
|
|
408
|
+
.update(JSON.stringify(plan))
|
|
409
|
+
.digest("hex");
|
|
410
|
+
const continuationPath = path.join(runDir, ".runtime", "frontend-recovery", `${trigger.requestId}.json`);
|
|
411
|
+
await writeJsonAtomic(continuationPath, {
|
|
412
|
+
schemaVersion: 1,
|
|
413
|
+
parentRunId: state.runId,
|
|
414
|
+
requestId: trigger.requestId,
|
|
415
|
+
failureSource: trigger.failureSource,
|
|
416
|
+
failureOwner: plan.failureOwner,
|
|
417
|
+
resetRootNodeId: plan.resetRootNodeId,
|
|
418
|
+
recoveryMode: trigger.recoveryMode,
|
|
419
|
+
planHash,
|
|
420
|
+
plan,
|
|
421
|
+
});
|
|
422
|
+
const operationInput = {
|
|
423
|
+
clientRequestId: `dag-recovery-${state.runId}`,
|
|
424
|
+
action: "dagRunTask",
|
|
425
|
+
actionParams: {
|
|
426
|
+
parentRunId: state.runId,
|
|
427
|
+
requestId: trigger.requestId,
|
|
428
|
+
failureSource: trigger.failureSource,
|
|
429
|
+
failureOwner: plan.failureOwner,
|
|
430
|
+
resetRootNodeId: plan.resetRootNodeId,
|
|
431
|
+
protocolFailureReason: trigger.protocolFailureReason,
|
|
432
|
+
recoveryMode: trigger.recoveryMode,
|
|
433
|
+
planHash,
|
|
434
|
+
},
|
|
435
|
+
cliArgs: [
|
|
436
|
+
"dag",
|
|
437
|
+
"rerun-task",
|
|
438
|
+
"--run-id",
|
|
439
|
+
state.runId,
|
|
440
|
+
"--reason",
|
|
441
|
+
trigger.protocolFailureReason ?? "frontend automatic recovery",
|
|
442
|
+
"--request-id",
|
|
443
|
+
trigger.requestId,
|
|
444
|
+
"--frontend-recovery",
|
|
445
|
+
"--json",
|
|
446
|
+
],
|
|
447
|
+
};
|
|
448
|
+
let created;
|
|
449
|
+
if (effectiveLease.lease.executionRef) {
|
|
450
|
+
// Replay the exact operation bound by the durable lease. A missing record
|
|
451
|
+
// cannot be replaced with a new operation id without violating the fence.
|
|
452
|
+
const existing = await input.deps.store.get(effectiveLease.lease.executionRef);
|
|
453
|
+
if (!existing) {
|
|
454
|
+
throw new Error(`frontend recovery lease operation not found: ${effectiveLease.lease.executionRef}`);
|
|
455
|
+
}
|
|
456
|
+
if (existing.clientRequestId !== operationInput.clientRequestId ||
|
|
457
|
+
existing.action !== operationInput.action ||
|
|
458
|
+
existing.payloadHash !==
|
|
459
|
+
computeActionPayloadHash(operationInput.action, operationInput.actionParams)) {
|
|
460
|
+
throw new Error("frontend recovery lease execution identity drift: referenced operation payload does not match the recovery request");
|
|
461
|
+
}
|
|
462
|
+
created = { kind: "idempotent", operation: existing };
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
created = await input.deps.store.create(operationInput);
|
|
466
|
+
}
|
|
467
|
+
if (created.kind !== "conflict" && !observeOnly) {
|
|
468
|
+
// The durable lease must name the operation before controller execution.
|
|
469
|
+
// This also repairs a lease left behind by a crash before the original bind.
|
|
470
|
+
await bindDagRecoveryLeaseExecution({
|
|
471
|
+
leasePath: effectiveLease.leasePath,
|
|
472
|
+
lease: effectiveLease.lease,
|
|
473
|
+
executionRef: created.operation.operationId,
|
|
474
|
+
});
|
|
475
|
+
// Repair CLI args on both fresh and replayed operations. This closes the
|
|
476
|
+
// crash window after operation creation but before args were persisted.
|
|
477
|
+
await input.deps.store.update(created.operation.operationId, {
|
|
478
|
+
cliArgs: [
|
|
479
|
+
"dag",
|
|
480
|
+
"rerun-task",
|
|
481
|
+
"--run-id",
|
|
482
|
+
state.runId,
|
|
483
|
+
"--reason",
|
|
484
|
+
trigger.protocolFailureReason ?? "frontend automatic recovery",
|
|
485
|
+
"--request-id",
|
|
486
|
+
trigger.requestId,
|
|
487
|
+
"--frontend-recovery",
|
|
488
|
+
"--recovery-operation-id",
|
|
489
|
+
created.operation.operationId,
|
|
490
|
+
"--json",
|
|
491
|
+
],
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
if (created.kind !== "conflict" && observeOnly) {
|
|
495
|
+
return { kind: "observed", operation: created.operation };
|
|
496
|
+
}
|
|
497
|
+
return created;
|
|
498
|
+
}
|
|
499
|
+
export const SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS = new Set([
|
|
500
|
+
"review-pass",
|
|
501
|
+
"hard-verify-pass",
|
|
502
|
+
]);
|
|
@@ -55,7 +55,7 @@ src/commands/dag-validate.ts runDagValidate
|
|
|
55
55
|
| --- | --- | --- |
|
|
56
56
|
| 拓扑排序 | `src/workflows/dag/topo.ts` `topoSortToRanks` | Kahn 算法,返回 `string[][]` ranks 并检测环 |
|
|
57
57
|
| 单次 rank 执行 | `src/workflows/dag/scheduler.ts` `executeDagRanksOnce` | rank 间遍历、节点执行编排 |
|
|
58
|
-
| run 主循环 | `src/workflows/dag/runner.ts` `runDag`
|
|
58
|
+
| run 主循环 | `src/workflows/dag/runner.ts` `runDag`;checkpoint 生命周期在 `src/workflows/dag/checkpoint.ts` `executeDagCheckpoint` | 调用 scheduler + persistence + convergence;终态判定在 `src/workflows/dag/terminal-status.ts` `finalizeTerminalRunStatus` |
|
|
59
59
|
|
|
60
60
|
`scheduler.ts` `executeDagRanksOnce` 内每个 rank:
|
|
61
61
|
|