@tea-agent/loop-agent 0.25.4 → 0.25.5

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/AGENTS.md +6 -0
  2. package/CHANGELOG.md +40 -0
  3. package/dist/commands/client-recovery.js +209 -62
  4. package/dist/executors/dag-pi-executor.js +80 -15
  5. package/dist/executors/model-routing.js +1 -1
  6. package/dist/executors/shell-executor.js +127 -0
  7. package/dist/executors/shell-write-guard.js +21 -7
  8. package/dist/worker/console/repo-fingerprint.js +7 -1
  9. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +964 -0
  10. package/dist/workflows/dag/backend-test-case-manifest.js +39 -1
  11. package/dist/workflows/dag/backend-test-markdown-workflow.js +219 -16
  12. package/dist/workflows/dag/convergence/controller.js +134 -9
  13. package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
  14. package/dist/workflows/dag/init-hybrid.js +262 -75
  15. package/dist/workflows/dag/node-execution.js +64 -11
  16. package/dist/workflows/dag/prompt.js +118 -4
  17. package/dist/workflows/dag/retry-policy.js +5 -4
  18. package/dist/workflows/dag/scheduler.js +32 -5
  19. package/dist/workflows/dag/types.js +7 -4
  20. package/dist/workflows/dag/validate.js +3 -2
  21. package/docs/architecture/dag-execution.md +7 -4
  22. package/docs/architecture/runtime-boundaries.md +1 -1
  23. package/docs/templates/agent-dag.base.json +1 -1
  24. package/docs/templates/agent-dag.final-verification.json +1 -1
  25. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  26. package/docs/templates/backend-test-dag.json +40 -13
  27. package/docs/templates/frontend-test-dag.json +32 -2
  28. package/docs/templates/hybrid-dag.json +1 -1
  29. package/examples/decision-gate-agent-dag.json +1 -1
  30. package/examples/example-dag.json +1 -1
  31. package/examples/hybrid-loop-agent-dag.json +1 -1
  32. package/harness.json +1 -1
  33. package/package.json +1 -1
  34. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  35. package/skills/loop-agent/references/model-routing.md +1 -1
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { recordDecisionEnvelopeForNode, shouldPauseOnHumanEscalation, writeHumanEscalationArtifacts, } from "./decision-envelope.js";
6
6
  import { writeNodeRecord, writeNodeSkillArtifacts } from "./run-store.js";
7
7
  import { resolveContextPolicy } from "./context-policy.js";
8
- import { buildDagNodePromptEnvelope } from "./prompt.js";
8
+ import { buildDagNodePromptEnvelope, formatConvergenceFeedbackBlock, } from "./prompt.js";
9
9
  import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
10
10
  import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
11
11
  import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
@@ -25,6 +25,7 @@ export function buildNodePrompt(spec, task, upstream, options) {
25
25
  resolvedSkills: policy.resolveSkills(spec, task),
26
26
  maxUpstreamChars: policy.resolveMaxUpstreamChars(task),
27
27
  projectGovernanceContext: options?.projectGovernanceContext,
28
+ convergenceFeedback: options?.convergenceFeedback,
28
29
  });
29
30
  }
30
31
  function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory, previousProtocolReason) {
@@ -75,6 +76,7 @@ export async function buildNodePromptWithResolvedSkillInstructions(spec, task, u
75
76
  resolvedSkillInstructions,
76
77
  maxUpstreamChars: policy.resolveMaxUpstreamChars(task),
77
78
  projectGovernanceContext: options?.projectGovernanceContext,
79
+ convergenceFeedback: options?.convergenceFeedback,
78
80
  }),
79
81
  resolvedSkills: skillInstructionMetadata(resolvedSkillInstructions),
80
82
  };
@@ -90,6 +92,41 @@ export function parseProcessVerdict(node) {
90
92
  }
91
93
  return "unknown";
92
94
  }
95
+ /**
96
+ * Derive a bounded convergence feedback block for the next-round
97
+ * supervisor/repair node from `state.convergence`. Deterministic: reads the
98
+ * last passHistory entry and its preservedNodeRecordPath pointers; never
99
+ * searches the run directory. Returns undefined on pass 1 (no prior evidence).
100
+ */
101
+ export function deriveConvergenceFeedback(state) {
102
+ const convergence = state.convergence;
103
+ if (!convergence?.enabled || convergence.currentPass <= 1)
104
+ return undefined;
105
+ const last = convergence.passHistory.at(-1);
106
+ if (!last)
107
+ return undefined;
108
+ return {
109
+ pass: last.pass,
110
+ reason: last.reason,
111
+ hardVerifyFailureCategory: last.hardVerifyFailureCategory,
112
+ processVerdict: last.processVerdict,
113
+ reviewVerdict: last.reviewVerdict,
114
+ reviewFailureCategory: last.reviewFailureCategory,
115
+ shellSuccessCount: last.shellSuccessCount,
116
+ repairArtifact: last.repairArtifact
117
+ ? {
118
+ failureClass: last.repairArtifact.failureClass,
119
+ rootCause: last.repairArtifact.rootCause,
120
+ fixScope: last.repairArtifact.fixScope,
121
+ invariant: last.repairArtifact.invariant,
122
+ }
123
+ : undefined,
124
+ evidenceRefs: last.artifactRefs.map((ref) => ({
125
+ nodeId: ref.nodeId,
126
+ preservedNodeRecordPath: ref.preservedNodeRecordPath,
127
+ })),
128
+ };
129
+ }
93
130
  function assertRepairArtifactVerdictMatchesSupervisor(input) {
94
131
  const supervisorVerdict = parseProcessVerdict(input.node);
95
132
  if (supervisorVerdict === "unknown")
@@ -207,10 +244,10 @@ export async function executeDagNode(input) {
207
244
  return;
208
245
  }
209
246
  }
210
- const isDynamicTask = Boolean(task.dynamicExpansion
211
- || task.dynamicReduction
212
- || task.dynamicCondition
213
- || task.dynamicLoopUntil);
247
+ const isDynamicTask = Boolean(task.dynamicExpansion ||
248
+ task.dynamicReduction ||
249
+ task.dynamicCondition ||
250
+ task.dynamicLoopUntil);
214
251
  let snapshotPrompt;
215
252
  if (state.skillSnapshotRef) {
216
253
  try {
@@ -226,6 +263,17 @@ export async function executeDagNode(input) {
226
263
  snapshot: skillSnapshot,
227
264
  projectGovernanceContext,
228
265
  });
266
+ const feedbackBlock = formatConvergenceFeedbackBlock(deriveConvergenceFeedback(state));
267
+ if (feedbackBlock &&
268
+ (task.role === "supervisor" ||
269
+ task.role === "implementer" ||
270
+ task.id === "process-supervisor-pi" ||
271
+ task.id === "repair-pi")) {
272
+ snapshotPrompt = {
273
+ ...snapshotPrompt,
274
+ prompt: `${snapshotPrompt.prompt}\n\n<convergence_feedback>\n${feedbackBlock}\n</convergence_feedback>`,
275
+ };
276
+ }
229
277
  }
230
278
  }
231
279
  catch (error) {
@@ -296,7 +344,10 @@ export async function executeDagNode(input) {
296
344
  }
297
345
  else {
298
346
  ({ prompt, resolvedSkills } =
299
- await buildNodePromptWithResolvedSkillInstructions(spec, task, state.nodes, cwd, { projectGovernanceContext }));
347
+ await buildNodePromptWithResolvedSkillInstructions(spec, task, state.nodes, cwd, {
348
+ projectGovernanceContext,
349
+ convergenceFeedback: deriveConvergenceFeedback(state),
350
+ }));
300
351
  }
301
352
  }
302
353
  catch (error) {
@@ -433,8 +484,7 @@ export async function executeDagNode(input) {
433
484
  node.durationMs =
434
485
  retryPolicy === undefined
435
486
  ? attemptRecord.durationMs
436
- : attempts.reduce((sum, attempt) => sum + (attempt.durationMs ?? 0), 0) +
437
- totalBackoffMs;
487
+ : attempts.reduce((sum, attempt) => sum + (attempt.durationMs ?? 0), 0) + totalBackoffMs;
438
488
  node.stdout = result.stdout;
439
489
  node.stderr = result.stderr;
440
490
  node.failureCategory = result.failureCategory;
@@ -493,9 +543,10 @@ export async function executeDagNode(input) {
493
543
  !terminalResult.ok &&
494
544
  terminalResult.failureCategory === "protocol-invalid" &&
495
545
  task.outputProtocol) {
496
- const fullText = terminalResult.assistantText && terminalResult.assistantText.trim().length > 0
546
+ const fullText = terminalResult.assistantText &&
547
+ terminalResult.assistantText.trim().length > 0
497
548
  ? terminalResult.assistantText
498
- : terminalResult.stdout ?? "";
549
+ : (terminalResult.stdout ?? "");
499
550
  const normalized = normalizeReviewVerdictAfterRetries(task.outputProtocol, fullText);
500
551
  if (normalized.ok) {
501
552
  const normalizedText = normalized.normalizedOutput;
@@ -590,7 +641,9 @@ export async function executeDagNode(input) {
590
641
  const artifactPath = path.join(runDir, task.shell.jsonArtifactGate.outputDir, task.shell.jsonArtifactGate.artifactName);
591
642
  const bytes = await readFile(artifactPath);
592
643
  node.structuredArtifactPath = artifactPath;
593
- node.structuredArtifactSha256 = createHash("sha256").update(bytes).digest("hex");
644
+ node.structuredArtifactSha256 = createHash("sha256")
645
+ .update(bytes)
646
+ .digest("hex");
594
647
  node.structuredArtifactSchemaId = task.shell.jsonArtifactGate.schemaId;
595
648
  }
596
649
  else if (task.shell?.backendTestPipeline === "classification-result-context" ||
@@ -58,7 +58,7 @@ export function formatOutputLanguageBlock(language = DEFAULT_DAG_OUTPUT_LANGUAGE
58
58
  }
59
59
  export function buildUpstreamContext(task, upstream, maxChars = MAX_UPSTREAM_CHARS) {
60
60
  const sections = [];
61
- const toleratedErrors = new Set(task.failureAwareDependsOn ?? []);
61
+ const toleratedFailures = new Set(task.failureAwareDependsOn ?? []);
62
62
  for (const depId of task.depends_on) {
63
63
  const record = upstream[depId];
64
64
  const stdout = record?.stdout?.trim() ? record.stdout : "";
@@ -72,10 +72,12 @@ export function buildUpstreamContext(task, upstream, maxChars = MAX_UPSTREAM_CHA
72
72
  // explicitly so the node can read the failure without the upstream being
73
73
  // rewritten to FINISHED.
74
74
  if (record?.status === "ERROR" &&
75
- toleratedErrors.has(depId) &&
75
+ toleratedFailures.has(depId) &&
76
76
  upstreamText) {
77
77
  const { preview, truncated } = formatStdoutPreview(upstreamText, maxChars);
78
- let section = `## Upstream failure evidence: ${depId} (status=ERROR${record.failureCategory ? `, failureCategory=${record.failureCategory}` : ""})\n${preview}`;
78
+ let section = `## Upstream failure evidence: ${depId} (status=ERROR${record.failureCategory
79
+ ? `, failureCategory=${record.failureCategory}`
80
+ : ""})\n${preview}`;
79
81
  const artifactKind = stdout
80
82
  ? "stdout"
81
83
  : assistantText
@@ -99,6 +101,15 @@ export function buildUpstreamContext(task, upstream, maxChars = MAX_UPSTREAM_CHA
99
101
  sections.push(section);
100
102
  continue;
101
103
  }
104
+ if (record?.status === "SKIPPED" && toleratedFailures.has(depId)) {
105
+ const reason = record.skippedReason ?? "unknown";
106
+ sections.push([
107
+ `## Upstream failure evidence: ${depId} (status=SKIPPED)`,
108
+ `Skipped reason: ${reason}`,
109
+ "This dependency was skipped after an upstream failure. Preserve the failed/partial_failed outcome and do not infer success.",
110
+ ].join("\n"));
111
+ continue;
112
+ }
102
113
  if (!record || record.status !== "FINISHED" || !upstreamText)
103
114
  continue;
104
115
  const artifactKind = stdout ? "stdout" : "assistant";
@@ -132,6 +143,97 @@ function buildReadOnlyBoundary(writePolicy) {
132
143
  "Return findings in the assistant response/stdout only; the DAG runner persists node artifacts under .harness/dag-runs/<state>/<run-id>/<node-id>/.",
133
144
  ];
134
145
  }
146
+ /** Max chars for each text field in the convergence feedback block. */
147
+ const CONVERGENCE_FEEDBACK_FIELD_MAX_CHARS = 600;
148
+ /** Max chars for one list item/path in convergence feedback. */
149
+ const CONVERGENCE_FEEDBACK_ITEM_MAX_CHARS = 240;
150
+ /** Max items retained from an untrusted list-shaped feedback field. */
151
+ const CONVERGENCE_FEEDBACK_LIST_MAX_ITEMS = 8;
152
+ /** Hard cap for the complete rendered feedback block. */
153
+ const CONVERGENCE_FEEDBACK_BLOCK_MAX_CHARS = 4_000;
154
+ const CONVERGENCE_FEEDBACK_TRUNCATION_MARKER = "\n- [convergence feedback truncated]";
155
+ function boundFeedbackText(value, maxChars = CONVERGENCE_FEEDBACK_FIELD_MAX_CHARS) {
156
+ if (!value)
157
+ return undefined;
158
+ if (value.length <= maxChars)
159
+ return value;
160
+ return `${value.slice(0, maxChars - 1)}…`;
161
+ }
162
+ function boundFeedbackList(values) {
163
+ const items = values
164
+ .slice(0, CONVERGENCE_FEEDBACK_LIST_MAX_ITEMS)
165
+ .map((value) => boundFeedbackText(value, CONVERGENCE_FEEDBACK_ITEM_MAX_CHARS) ?? "");
166
+ return {
167
+ items,
168
+ omitted: Math.max(0, values.length - items.length),
169
+ };
170
+ }
171
+ function capConvergenceFeedbackBlock(block) {
172
+ if (block.length <= CONVERGENCE_FEEDBACK_BLOCK_MAX_CHARS)
173
+ return block;
174
+ const prefixLength = Math.max(0, CONVERGENCE_FEEDBACK_BLOCK_MAX_CHARS -
175
+ CONVERGENCE_FEEDBACK_TRUNCATION_MARKER.length);
176
+ return `${block.slice(0, prefixLength)}${CONVERGENCE_FEEDBACK_TRUNCATION_MARKER}`;
177
+ }
178
+ /**
179
+ * Format the prior supervised convergence pass as a bounded, structured block
180
+ * for the next-round supervisor/repair node. Deterministic: uses
181
+ * preservedNodeRecordPath pointers written by the convergence controller, never
182
+ * run-directory guessing. Returns undefined when there is no prior pass.
183
+ */
184
+ export function formatConvergenceFeedbackBlock(feedback) {
185
+ if (!feedback)
186
+ return undefined;
187
+ const lines = [];
188
+ lines.push(`## Previous convergence pass ${feedback.pass} (reason=${boundFeedbackText(feedback.reason)})`);
189
+ lines.push("This is the prior bounded-recovery pass that failed. Use it as authoritative evidence for this round; do not repeat a no-op or infer success.");
190
+ if (feedback.hardVerifyFailureCategory) {
191
+ lines.push(`- hardVerifyFailureCategory: ${boundFeedbackText(feedback.hardVerifyFailureCategory)}`);
192
+ }
193
+ if (feedback.processVerdict) {
194
+ lines.push(`- processVerdict: ${feedback.processVerdict}`);
195
+ }
196
+ if (feedback.reviewVerdict) {
197
+ lines.push(`- reviewVerdict: ${feedback.reviewVerdict}`);
198
+ }
199
+ if (feedback.reviewFailureCategory) {
200
+ lines.push(`- reviewFailureCategory: ${boundFeedbackText(feedback.reviewFailureCategory)}`);
201
+ }
202
+ if (feedback.shellSuccessCount !== undefined) {
203
+ lines.push(`- shellSuccessCount: ${feedback.shellSuccessCount}`);
204
+ }
205
+ const repair = feedback.repairArtifact;
206
+ if (repair) {
207
+ const parts = [];
208
+ if (repair.failureClass)
209
+ parts.push(`failureClass=${boundFeedbackText(repair.failureClass)}`);
210
+ if (repair.rootCause)
211
+ parts.push(`rootCause=${boundFeedbackText(repair.rootCause)}`);
212
+ if (repair.invariant)
213
+ parts.push(`invariant=${boundFeedbackText(repair.invariant)}`);
214
+ if (repair.fixScope && repair.fixScope.length > 0) {
215
+ const boundedScope = boundFeedbackList(repair.fixScope);
216
+ const omitted = boundedScope.omitted > 0
217
+ ? `, … (+${boundedScope.omitted} more)`
218
+ : "";
219
+ parts.push(`fixScope=${boundedScope.items.join(", ")}${omitted}`);
220
+ }
221
+ if (parts.length > 0)
222
+ lines.push(`- priorRepairArtifact: ${parts.join("; ")}`);
223
+ }
224
+ const refs = feedback.evidenceRefs.filter((ref) => ref.preservedNodeRecordPath);
225
+ if (refs.length > 0) {
226
+ lines.push("- previous failure evidence (read-only runner evidence — use read tool to fetch; do not edit):");
227
+ const boundedRefs = refs.slice(0, CONVERGENCE_FEEDBACK_LIST_MAX_ITEMS);
228
+ for (const ref of boundedRefs) {
229
+ lines.push(` - ${boundFeedbackText(ref.nodeId, CONVERGENCE_FEEDBACK_ITEM_MAX_CHARS)}: ${boundFeedbackText(ref.preservedNodeRecordPath, CONVERGENCE_FEEDBACK_ITEM_MAX_CHARS)}`);
230
+ }
231
+ if (refs.length > boundedRefs.length) {
232
+ lines.push(` - … (+${refs.length - boundedRefs.length} more)`);
233
+ }
234
+ }
235
+ return capConvergenceFeedbackBlock(lines.join("\n"));
236
+ }
135
237
  function formatProjectGovernanceContext(ctx) {
136
238
  if (!ctx || !ctx.applicable)
137
239
  return undefined;
@@ -174,7 +276,7 @@ function formatProjectGovernanceContext(ctx) {
174
276
  return lines.join("\n");
175
277
  }
176
278
  export function buildDagNodePromptEnvelope(input) {
177
- const { spec, task, upstream, resolvedSkills = [], resolvedSkillInstructions = [], maxUpstreamChars = MAX_UPSTREAM_CHARS, projectGovernanceContext, } = input;
279
+ const { spec, task, upstream, resolvedSkills = [], resolvedSkillInstructions = [], maxUpstreamChars = MAX_UPSTREAM_CHARS, projectGovernanceContext, convergenceFeedback, } = input;
178
280
  const objective = spec.objective ?? spec.title;
179
281
  const successCriteria = formatBulletList(spec.successCriteria, "(none specified)");
180
282
  const globalConstraints = formatBulletList(spec.globalConstraints, "(none specified)");
@@ -229,6 +331,18 @@ export function buildDagNodePromptEnvelope(input) {
229
331
  if (governanceSection) {
230
332
  sections.push(`<project_governance_context>\n${governanceSection}\n</project_governance_context>`);
231
333
  }
334
+ // Only supervisor/repair nodes consume the prior convergence pass evidence.
335
+ // This keeps the bounded-recovery loop from repeating no-ops on round 2+.
336
+ if (convergenceFeedback &&
337
+ (task.role === "supervisor" ||
338
+ task.role === "implementer" ||
339
+ task.id === "process-supervisor-pi" ||
340
+ task.id === "repair-pi")) {
341
+ const feedbackSection = formatConvergenceFeedbackBlock(convergenceFeedback);
342
+ if (feedbackSection) {
343
+ sections.push(`<convergence_feedback>\n${feedbackSection}\n</convergence_feedback>`);
344
+ }
345
+ }
232
346
  sections.push(`<task>\n${task.subtask_prompt}\n</task>`);
233
347
  return sections.join("\n\n");
234
348
  }
@@ -37,6 +37,7 @@ const RETRY_SAFE_PI_ROLES = new Set([
37
37
  "scout",
38
38
  "reviewer",
39
39
  "verifier",
40
+ "supervisor",
40
41
  "closeout",
41
42
  ]);
42
43
  export const dagRetryCategorySchema = z.enum(ALL_DAG_RETRY_CATEGORIES);
@@ -120,14 +121,14 @@ export function isRetryablePiFailureCategory(rawFailureCategory, options = {}) {
120
121
  *
121
122
  * A safe candidate is:
122
123
  * - executor === "pi"
123
- * - role is planner/scout/reviewer/verifier/closeout (never supervisor/implementer)
124
+ * - role is planner/scout/reviewer/verifier/supervisor/closeout (never implementer)
124
125
  * - writePolicy is read-only/none/default and toolProfile is not write
125
126
  * - NOT dynamic (no dynamicExpansion/Reduction/Condition/LoopUntil)
126
127
  * - NOT a decision gate
127
128
  *
128
- * Writers and source supervisors can have non-idempotent side effects or
129
- * control-flow meaning and must not auto-retry. Dynamic nodes expand into
130
- * children and must not retry at the controller level.
129
+ * Writers can have non-idempotent side effects and must not auto-retry.
130
+ * Read-only, non-dynamic supervisors are safe because they only classify
131
+ * settled evidence; dynamic nodes still do not retry at the controller level.
131
132
  */
132
133
  export function isSafeReadOnlyPiRetryCandidate(task) {
133
134
  if (task.executor !== "pi")
@@ -13,11 +13,11 @@ export function isConditionSkippedReason(reason) {
13
13
  * soft; the node runs when every dep is FINISHED or soft condition-skip and at
14
14
  * least one is FINISHED (OR-join after condition). Opt-in only — never default.
15
15
  */
16
- function dependencyReadiness(task, nodes) {
16
+ function dependencyReadiness(task, nodes, tasksById) {
17
17
  if (task.depends_on.length === 0)
18
18
  return "run";
19
19
  const softConditionJoin = task.dependsPolicy === "all-or-condition-skip";
20
- const toleratedErrors = new Set(task.failureAwareDependsOn ?? []);
20
+ const toleratedFailures = new Set(task.failureAwareDependsOn ?? []);
21
21
  let hasFinished = false;
22
22
  let hasPendingUpstream = false;
23
23
  let hasHardBlock = false;
@@ -40,7 +40,7 @@ function dependencyReadiness(task, nodes) {
40
40
  // consume a declared upstream ERROR as settled failure evidence. This
41
41
  // does not rewrite the upstream status; it only allows the recovery
42
42
  // node to become runnable so it can read the failure evidence.
43
- if (toleratedErrors.has(depId)) {
43
+ if (toleratedFailures.has(depId)) {
44
44
  hasFinished = true;
45
45
  continue;
46
46
  }
@@ -48,6 +48,15 @@ function dependencyReadiness(task, nodes) {
48
48
  continue;
49
49
  }
50
50
  if (dep.status === "SKIPPED") {
51
+ // A failure-aware closeout may also consume an explicitly declared
52
+ // dependency that was skipped only because one of its ancestors failed.
53
+ // Follow the settled dependency chain to an actual ERROR; condition or
54
+ // manually skipped branches remain fail-closed.
55
+ if (toleratedFailures.has(depId) &&
56
+ isFailureDerivedSkipped(depId, nodes, tasksById)) {
57
+ hasFinished = true;
58
+ continue;
59
+ }
51
60
  if (softConditionJoin &&
52
61
  isConditionSkippedReason(dep.skippedReason)) {
53
62
  continue;
@@ -63,6 +72,24 @@ function dependencyReadiness(task, nodes) {
63
72
  return "run";
64
73
  return "skip";
65
74
  }
75
+ function isFailureDerivedSkipped(nodeId, nodes, tasksById, visiting = new Set()) {
76
+ const node = nodes[nodeId];
77
+ if (node?.status !== "SKIPPED" ||
78
+ node.skippedReason !== "upstream dependency failed or was skipped" ||
79
+ visiting.has(nodeId)) {
80
+ return false;
81
+ }
82
+ const task = tasksById.get(nodeId);
83
+ if (!task)
84
+ return false;
85
+ const nextVisiting = new Set(visiting).add(nodeId);
86
+ return task.depends_on.some((upstreamId) => {
87
+ const upstream = nodes[upstreamId];
88
+ return (upstream?.status === "ERROR" ||
89
+ (upstream?.status === "SKIPPED" &&
90
+ isFailureDerivedSkipped(upstreamId, nodes, tasksById, nextVisiting)));
91
+ });
92
+ }
66
93
  function conditionSkippedByAncestor(task, nodes) {
67
94
  return task.depends_on.some((depId) => isConditionSkippedReason(nodes[depId]?.skippedReason));
68
95
  }
@@ -86,7 +113,7 @@ export async function executeDagRanksOnce(input) {
86
113
  });
87
114
  const runnable = pending.filter((id) => {
88
115
  const task = input.tasksById.get(id);
89
- return dependencyReadiness(task, input.state.nodes) === "run";
116
+ return (dependencyReadiness(task, input.state.nodes, input.tasksById) === "run");
90
117
  });
91
118
  const conditionSettled = [];
92
119
  for (const id of runnable) {
@@ -117,7 +144,7 @@ export async function executeDagRanksOnce(input) {
117
144
  const actuallyRunnable = runnable.filter((id) => !conditionSkippedSet.has(id));
118
145
  const blocked = pending.filter((id) => {
119
146
  const task = input.tasksById.get(id);
120
- return dependencyReadiness(task, input.state.nodes) === "skip";
147
+ return (dependencyReadiness(task, input.state.nodes, input.tasksById) === "skip");
121
148
  });
122
149
  for (const id of blocked) {
123
150
  const node = input.state.nodes[id];
@@ -279,6 +279,7 @@ export const dagBackendTestPipelineSchema = z.enum([
279
279
  "markdown-cases",
280
280
  "markdown-traceability",
281
281
  "markdown-execute-html",
282
+ "markdown-manifest",
282
283
  ]);
283
284
  export const dagFrontendTestEvidenceValidationSchema = z.object({}).strict();
284
285
  export const dagFrontendTestCaseChecklistSchema = z.object({}).strict();
@@ -326,8 +327,9 @@ export const dagShellConfigSchema = z.object({
326
327
  frontendLintBaseline: dagFrontendLintBaselineSchema.optional(),
327
328
  frontendVerificationBundle: dagFrontendVerificationBundleSchema.optional(),
328
329
  frontendReviewContext: dagFrontendReviewContextSchema.optional(),
329
- frontendTestEvidenceValidation: dagFrontendTestEvidenceValidationSchema.optional(),
330
330
  frontendTestCaseChecklist: dagFrontendTestCaseChecklistSchema.optional(),
331
+ frontendTestEvidenceValidation: dagFrontendTestEvidenceValidationSchema.optional(),
332
+ frontendTestL5Report: z.object({}).strict().optional(),
331
333
  frontendTestHtmlReport: dagFrontendTestHtmlReportSchema.optional(),
332
334
  backendTestPipeline: dagBackendTestPipelineSchema.optional(),
333
335
  /** JaCoCo coverage collection for backend-test (Java services). When set, node 7 dumps coverage over TCP from a JaCoCo tcpserver agent and feeds it to the L-5 dashboard. */
@@ -483,8 +485,9 @@ export const dagTaskSchema = z.object({
483
485
  */
484
486
  dependsPolicy: z.enum(["all", "all-or-condition-skip"]).optional(),
485
487
  /**
486
- * Explicit opt-in: upstream node ids whose ERROR terminal state this
487
- * read-only node may consume as settled failure evidence. Validation
488
+ * Explicit opt-in: upstream node ids whose ERROR terminal state, or whose
489
+ * failure-derived SKIPPED state, this read-only node may consume as settled
490
+ * failure evidence. Validation
488
491
  * restricts this to read-only Pi recovery/diagnosis/closeout nodes; writers,
489
492
  * shell verifiers, hard gates, and dynamic/decision nodes remain fail-closed.
490
493
  * Every id must also appear in depends_on.
@@ -680,7 +683,7 @@ export const dagSpecSchema = z
680
683
  export const DEFAULT_DAG_EXECUTOR_MODELS = {
681
684
  pi: {
682
685
  LOW: "gpt-5.3-codex-spark",
683
- MED: "glm-5.2",
686
+ MED: "gpt-5.5",
684
687
  HIGH: "gpt-5.5",
685
688
  },
686
689
  };
@@ -464,6 +464,7 @@ function validateShellTaskConfig(task, spec, issues) {
464
464
  !shell.frontendReviewContext &&
465
465
  !shell.frontendTestCaseChecklist &&
466
466
  !shell.frontendTestEvidenceValidation &&
467
+ !shell.frontendTestL5Report &&
467
468
  !shell.frontendTestHtmlReport) {
468
469
  issues.push({
469
470
  type: "missing-shell-commands",
@@ -592,7 +593,7 @@ function validateRetryPolicyTaskConfig(task, issues) {
592
593
  if (!isSafeReadOnlyPiRetryCandidate(task)) {
593
594
  issues.push({
594
595
  type: "invalid-retry-policy",
595
- message: `task ${task.id} declares retryPolicy but is not a safe read-only non-dynamic Pi node; retry is only allowed for read-only/none Pi planner/scout/reviewer/verifier/closeout nodes without write, supervisor, or dynamic capabilities`,
596
+ message: `task ${task.id} declares retryPolicy but is not a safe read-only non-dynamic Pi node; retry is only allowed for read-only/none Pi planner/scout/reviewer/verifier/supervisor/closeout nodes without write or dynamic capabilities`,
596
597
  });
597
598
  }
598
599
  }
@@ -664,7 +665,7 @@ function validateFailureAwareDependsOn(task, spec, issues) {
664
665
  if (!isReadOnlyPiRecoveryNode) {
665
666
  issues.push({
666
667
  type: "invalid-failure-aware-dependency",
667
- message: `task ${task.id} declares failureAwareDependsOn but failure-aware dependencies are allowed only for read-only recovery/diagnosis/supervision/closeout-equivalent Pi nodes (reviewer, verifier, supervisor, closeout); writer and shell verification nodes may not tolerate upstream ERROR`,
668
+ message: `task ${task.id} declares failureAwareDependsOn but failure-aware dependencies are allowed only for read-only recovery/diagnosis/supervision/closeout-equivalent Pi nodes (reviewer, verifier, supervisor, closeout); writer and shell verification nodes may not tolerate upstream ERROR or failure-derived SKIPPED`,
668
669
  });
669
670
  return;
670
671
  }
@@ -131,12 +131,15 @@ decision envelope 中的 **model verdict**(`decision` / `riskLevel` 等解析
131
131
  - **显式 recovery mutation**:`dag reconcile-run`(`src/commands/dag-reconcile-run.ts`,命令层)默认仅检查;只有给出 `--action supersede|abandon` + reason,且 liveness 证明 runner 已停止时,才在原 lifecycle 写 reconciliation/state 并迁移到 `completed/`。仍为 `RUNNING` 的节点保持失败语义,但以 `reconciledAt - startedAt` 补齐节点和当前 attempt 的耗时,原始状态继续冻结在 `reconciliation.json`。它不是修改既有 completed history 的通用入口。Observe / status / doctor 始终只读。
132
132
  - **status 枚举**:`DagRunState.status`;`TERMINAL_RUN_STATUSES` 判终态;`isTerminalDagRunStatus` 工具函数。
133
133
 
134
- ## convergence(可选、supervised
134
+ ## convergencesupervised 默认启用、有界自愈)
135
135
 
136
136
  - 控制器:`src/workflows/dag/convergence/controller.ts` `runConvergencePassController`,在 runner rank 间被调用。
137
- - 特性默认 **off**(`task/config-types.ts` `convergence` 默认 `{ enabled: false }`)。
138
- - 启用后按 `maxPasses`(默认 3)做多轮 repair,回归时可 `pauseOnRegression`。
139
- - 产物落在 `<runDir>/convergence/pass-<n>/`。
137
+ - supervised 路径的 convergence 由 task `maxFixLoops` 派生(`init-hybrid.ts` `resolveSupervisedConvergence`):`maxFixLoops > 0` ⇒ `enabled: true`、`maxPasses = maxFixLoops + 1`(初次执行 + 修复预算)、`chainNodeIds` 覆盖 `process-supervisor-pi → process-gate-shell → repair-pi → hard-verify-shell → review-pi → review-verdict-recovery-pi → review-gate-shell`;`maxFixLoops === 0` ⇒ `enabled: false`。task-level `convergence` config 仍默认 `{ enabled: false }`(opt-in),不会被 supervised 路径以外的模板启用。
138
+ - 触发源:hard verify 可恢复失败(`nonzero-exit` 等)与 review 合法 `request-revision`(`review-gate-shell` ERROR)进入同一有界恢复链;两者都套用 non-retry / regression / max-passes 守卫。
139
+ - 重算边界:进入下一轮时重置 convergence chain,以及 `hard-verify-shell` 的完整后代闭包(包括已 `FINISHED` 的 authority/governance 节点与 failure-aware closeout),防止修复后复用首轮陈旧治理或交接证据。
140
+ - 证据回灌:每轮 pass 记录写入 `state.convergence.passHistory`,并归档到 `<runDir>/convergence/pass-<n>/`;下一轮 supervisor/repair 的 prompt 确定性注入上一轮失败类别、review verdict、repairArtifact 摘要与 `convergence/pass-<n>/<node>.json` 证据指针(`prompt.ts` `formatConvergenceFeedbackBlock`),并对列表项数、单项长度和完整 block(4000 字符)设置硬上限,不依赖搜索 run 目录。
141
+ - 安全失败不重试:`write-guard` / `auth` / `missing-api-key` / `human-rejected` / `decision-gate-requires-human` / `timeout` / `spawn-error` 继续 fail-closed。
142
+ - 非 supervised 模板(未声明 `chainNodeIds` 且无 `review-gate-shell`)保持原 hard-verify-only 行为。
140
143
 
141
144
  ## 完成权威 = shell verification
142
145
 
@@ -196,7 +196,7 @@ Runtime 变更另需 `npm run typecheck` 及对应 targeted Vitest(见 exec pl
196
196
 
197
197
  | 面 | 路径 / 入口 | 边界 |
198
198
  |---|---|---|
199
- | OpenCode 项目插件 | `.opencode/plugins/loop-agent-transient-retry.js`(init surface `generated`) | 直接返回真实 `Hooks.event`,分发 `session.error` / `session.status` / `message.updated`;从 `client.session.status()` session map 判断内置 retry,以 `client.session.promptAsync()` 续接同一 session;只有成功完成的 assistant message 清零连续失败计数。认证/权限/配额/上下文溢出/取消/业务错误走 `plugin-ignore-permanent-error` |
199
+ | OpenCode 项目插件 | `.opencode/plugins/loop-agent-transient-retry.js`(init surface `generated`) | 直接返回真实 `Hooks.event`,分发 `session.error` / `session.status` / `session.idle` / `message.updated`;每个 session 保存 pending error,busy/retry/unknown/status API 失败只暂停恢复,后续 idle 事件重新驱动单一 worker。`client.session.status({ throwOnError: true })` 在退避前后确认 idle,`client.session.promptAsync(..., throwOnError: true)` 仅在 1.18.9 的 204 响应明确返回对象型 `data` 时计入一次 attempt 并续接同一 session;只有成功完成的 assistant message 清零连续失败计数。认证/权限/配额/上下文溢出/取消/业务错误走 `plugin-ignore-permanent-error` |
200
200
  | Pi 用户配置 | `~/.pi/agent/settings.json` | **不**进入项目 `.harness/init-surface.json` hash;仅 `--client-recovery=user` 可字段级补缺并原子写;`auto`/`project`/`off` 与 `check-update` 默认零写 home;读取时只有 `ENOENT` 视为缺文件,其他 I/O 错误 fail closed |
201
201
  | CLI mode | `--client-recovery=auto\|project\|user\|off`(默认 `auto`) | `auto`/`project` 只装项目插件;`user` = 项目插件 + 显式 Pi 合并;`off` 全跳过 |
202
202
  | Ownership | recorded sha256 + apply-safe | 插件缺失可补、与 recorded hash 一致可升级;用户改过 → model merge / human decision,禁止静默覆盖 |
@@ -59,7 +59,7 @@
59
59
  "executorModels": {
60
60
  "pi": {
61
61
  "LOW": "gpt-5.3-codex-spark",
62
- "MED": "glm-5.2",
62
+ "MED": "gpt-5.5",
63
63
  "HIGH": "gpt-5.5"
64
64
  }
65
65
  },
@@ -27,7 +27,7 @@
27
27
  "executorModels": {
28
28
  "pi": {
29
29
  "LOW": "gpt-5.3-codex-spark",
30
- "MED": "glm-5.2",
30
+ "MED": "gpt-5.5",
31
31
  "HIGH": "gpt-5.5"
32
32
  }
33
33
  },
@@ -84,7 +84,7 @@
84
84
  "executorModels": {
85
85
  "pi": {
86
86
  "LOW": "gpt-5.3-codex-spark",
87
- "MED": "glm-5.2",
87
+ "MED": "gpt-5.5",
88
88
  "HIGH": "gpt-5.5"
89
89
  }
90
90
  },