@tea-agent/loop-agent 0.25.4 → 0.25.6
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/AGENTS.md +6 -0
- package/CHANGELOG.md +60 -0
- package/dist/commands/client-recovery.js +209 -62
- package/dist/commands/init.js +68 -129
- package/dist/executors/dag-pi-executor.js +80 -15
- package/dist/executors/model-routing.js +1 -1
- package/dist/executors/shell-executor.js +127 -0
- package/dist/executors/shell-write-guard.js +21 -7
- package/dist/worker/console/repo-fingerprint.js +7 -1
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +1281 -0
- package/dist/workflows/dag/backend-test-case-manifest.js +59 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +236 -16
- package/dist/workflows/dag/convergence/controller.js +134 -9
- package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
- package/dist/workflows/dag/init-hybrid.js +270 -80
- package/dist/workflows/dag/node-execution.js +64 -11
- package/dist/workflows/dag/prompt.js +118 -4
- package/dist/workflows/dag/retry-policy.js +5 -4
- package/dist/workflows/dag/scheduler.js +32 -5
- package/dist/workflows/dag/types.js +7 -4
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/architecture/dag-execution.md +7 -4
- package/docs/architecture/runtime-boundaries.md +1 -1
- package/docs/init-surface.manifest.json +3 -1
- package/docs/templates/README.md +1 -0
- package/docs/templates/agent-dag.base.json +1 -1
- package/docs/templates/agent-dag.final-verification.json +1 -1
- package/docs/templates/agent-dag.supervised-implementation.json +1 -1
- package/docs/templates/backend-test-dag.json +41 -14
- package/docs/templates/frontend-test-dag.json +32 -2
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/init-managed-agents.md +137 -0
- package/examples/decision-gate-agent-dag.json +1 -1
- package/examples/example-dag.json +1 -1
- package/examples/hybrid-loop-agent-dag.json +1 -1
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +5 -4
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- 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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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, {
|
|
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 &&
|
|
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")
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
|
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 (
|
|
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
|
|
487
|
-
* read-only node may consume as settled
|
|
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: "
|
|
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
|
|
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
|
|
134
|
+
## convergence(supervised 默认启用、有界自愈)
|
|
135
135
|
|
|
136
136
|
- 控制器:`src/workflows/dag/convergence/controller.ts` `runConvergencePassController`,在 runner rank 间被调用。
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
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
|
|
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,禁止静默覆盖 |
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"docs/skills/README.md",
|
|
24
24
|
"docs/skills/vetted-skill-registry.md",
|
|
25
25
|
"docs/templates/README.md",
|
|
26
|
+
"docs/templates/init-managed-agents.md",
|
|
26
27
|
"docs/templates/init-evolution-review.md",
|
|
27
28
|
"docs/templates/exec-plan.md",
|
|
28
29
|
"docs/templates/production-readiness-checklist.md",
|
|
@@ -257,7 +258,8 @@
|
|
|
257
258
|
],
|
|
258
259
|
"initExcluded": [
|
|
259
260
|
"examples/example-dag.json",
|
|
260
|
-
"docs/skills/vetted-skill-registry.md"
|
|
261
|
+
"docs/skills/vetted-skill-registry.md",
|
|
262
|
+
"docs/templates/init-managed-agents.md"
|
|
261
263
|
],
|
|
262
264
|
"reviewTriggers": [
|
|
263
265
|
"AGENTS.md",
|
package/docs/templates/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 通用任务、报告与治理
|
|
8
8
|
|
|
9
|
+
- `init-managed-agents.md` — **package-only**:目标项目 `AGENTS.md` managed block 的可编辑正文真源(`__LOOP_AGENT_PROJECT_NAME__` / `__LOOP_AGENT_GOVERNANCE_ROOT__` 占位符;由 `init` 渲染注入,**不**复制到目标治理 `templates/`)。
|
|
9
10
|
- `project-start-checklist.md` — 开工检查。
|
|
10
11
|
- `feature-spec.md`、`sprint-contract.md` — 功能规格与短周期合同。
|
|
11
12
|
- `exec-plan.md`、`progress-log.md`、`qa-report.md` — 执行计划、交接与验证报告。
|