@tea-agent/loop-agent 0.44.0-next.10 → 0.44.0-next.12

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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi/sessions/index.js +6 -0
  4. package/dist/executors/dag-pi/sessions/plan-batches.js +280 -0
  5. package/dist/executors/dag-pi/sessions/plan-prompts.js +367 -0
  6. package/dist/executors/dag-pi/sessions/scout-parallel.js +197 -0
  7. package/dist/executors/dag-pi/sessions/segmented-plan.js +1184 -0
  8. package/dist/executors/dag-pi/sessions/writer-evidence.js +60 -0
  9. package/dist/executors/dag-pi-executor.js +12 -2075
  10. package/dist/executors/shell-executor.js +39 -11
  11. package/dist/worker/console/routes.js +5 -0
  12. package/dist/worker/console/static/app-icon.svg +39 -0
  13. package/dist/worker/console/static/index.html +3 -0
  14. package/dist/worker/console/static/manifest.webmanifest +19 -0
  15. package/dist/worker/observe/static/console-theme.js +38 -0
  16. package/dist/workflows/dag/checkpoint.js +686 -0
  17. package/dist/workflows/dag/frontend-repair.js +24 -0
  18. package/dist/workflows/dag/frontend-review-scopes.js +10 -2
  19. package/dist/workflows/dag/frontend-test-execution-evidence.js +163 -67
  20. package/dist/workflows/dag/frontend-test-framework-adapters.js +309 -0
  21. package/dist/workflows/dag/hybrid/sources.js +1812 -0
  22. package/dist/workflows/dag/hybrid/templates/backend-test.js +1807 -0
  23. package/dist/workflows/dag/hybrid/templates/frontend-test.js +702 -0
  24. package/dist/workflows/dag/hybrid/templates/frontend.js +1529 -0
  25. package/dist/workflows/dag/hybrid/templates/index.js +8 -0
  26. package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +449 -0
  27. package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +495 -0
  28. package/dist/workflows/dag/hybrid/templates/shared.js +101 -0
  29. package/dist/workflows/dag/hybrid/templates/standard.js +265 -0
  30. package/dist/workflows/dag/hybrid/types.js +32 -0
  31. package/dist/workflows/dag/init-hybrid.js +42 -7162
  32. package/dist/workflows/dag/runner.js +11 -1178
  33. package/dist/workflows/dag/terminal-status.js +502 -0
  34. package/docs/architecture/dag-execution.md +4 -4
  35. 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
+ ]);
@@ -9,7 +9,7 @@
9
9
  ```text
10
10
  src/commands/dag-run-task.ts runDagRunTask
11
11
  → src/application/dag/generate-task-dag.ts generateTaskDagUseCase
12
- └─ src/workflows/dag/init-hybrid.ts initHybridDagFromTask (生成 DagSpec)
12
+ └─ src/workflows/dag/init-hybrid.ts initHybridDagFromTask (生成 DagSpec;模板族在 hybrid/templates/,输入准备在 hybrid/types.ts + hybrid/sources.ts)
13
13
  └─ src/application/dag/validate-dag.ts validateDagUseCase (候选 + 最终校验)
14
14
  └─ assertSafeForExecution (执行前安全检查)
15
15
  └─ src/application/dag/run-dag.ts runDagUseCase (执行)
@@ -43,7 +43,7 @@ src/commands/dag-validate.ts runDagValidate
43
43
 
44
44
  ### 专用 taskKind 与模板选择(架构摘要)
45
45
 
46
- - `initHybridDagFromTask` 按 `task.json.taskKind`(及前端需求自动分类)选择专用拓扑:`frontend-implementation`、`frontend-test`、`backend-test`、`knowledge-sync`、`knowledge-graph-bootstrap` 等;默认 `standard` 走 governance profile 通用实现链。
46
+ - `initHybridDagFromTask` 按 `task.json.taskKind`(及前端需求自动分类)选择专用拓扑:`frontend-implementation`、`frontend-test`、`backend-test`、`knowledge-sync`、`knowledge-graph-bootstrap` 等;默认 `standard` 走 governance profile 通用实现链。入口只做分发与治理装饰,六个模板 builder 在 `src/workflows/dag/hybrid/templates/`。
47
47
  - **backend-test(0.17.x)**:Markdown-first 固定短链(环境硬门 → Markdown 用例/Review → 单次 pytest + HTML → 报告与 L-5);不在本页展开节点清单,见 `docs/runtime/backend-test-workflow.md` 与 completed markdown-first plan。
48
48
  - 专用模板不是新的 governance profile:风险等级仍由 profile 规则判断;写边界仍受 `allowedPaths` / `forbiddenPaths` / `writeSet` 约束。
49
49
 
@@ -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` / `executeDagCheckpoint` | 调用 scheduler + persistence + convergence |
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
 
@@ -73,7 +73,7 @@ src/commands/dag-validate.ts runDagValidate
73
73
  - schema:`src/workflows/dag/types.ts`
74
74
  `dagNodeExecutorSchema = z.enum(["pi","shell","static"])`;DagNodeExecutor 默认 `"pi"`(`executor: dagNodeExecutorSchema.default("pi")`)。
75
75
  - `executor: "cursor"` 在 schema refine 阶段抛 `CURSOR_DAG_EXECUTOR_REMOVED_ERROR`(Pi-only = ADR 0001)。
76
- - Pi handler 先 `requireModel(input)` 校验已解析 model,再委托 `executeDagPiNode`(`src/executors/dag-pi-executor.ts`)。
76
+ - Pi handler 先 `requireModel(input)` 校验已解析 model,再委托 `executeDagPiNode`(`src/executors/dag-pi-executor.ts`;领域工具、plan ledger、分段会话在 `src/executors/dag-pi/`)。
77
77
  - shell handler = `executeDagShellNode`(`src/executors/shell-executor.ts`);static handler = `executeDagStaticNode`(`src/executors/dag-static-executor.ts`)。
78
78
 
79
79
  Executor 不得依赖 commands 或 CLI formatting;Cursor 不在受治理路径(`runtime-boundaries.md` §Executors)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.44.0-next.10",
3
+ "version": "0.44.0-next.12",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",