@tea-agent/loop-agent 0.16.23 → 0.16.25
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/CHANGELOG.md +33 -13
- package/dist/cli/command-definitions.js +13 -0
- package/dist/cli/program.js +4 -0
- package/dist/commands/coverage-report.js +50 -0
- package/dist/executors/shell-presets.js +27 -26
- package/dist/executors/shell-verification.js +2 -0
- package/dist/task/config-types.js +15 -3
- package/dist/workflows/dag/backend-test-analysis-contract.js +21 -8
- package/dist/workflows/dag/backend-test-coverage-contract.js +202 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +84 -18
- package/dist/workflows/dag/backend-test-stability-contract.js +57 -0
- package/dist/workflows/dag/dynamic-runtime/map.js +129 -22
- package/dist/workflows/dag/init-hybrid.js +374 -272
- package/dist/workflows/dag/l5-report-metrics.js +36 -0
- package/dist/workflows/dag/types.js +7 -0
- package/docs/architecture/evolution.md +3 -1
- package/docs/local-development-environment.md +4 -0
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +2 -0
- package/docs/templates/backend-test-dag.json +40 -7
- package/docs/templates/backend-test-dag.retrospect.prompt.md +36 -7
- package/docs/templates/frontend-test-case-checklist.md +25 -0
- package/docs/templates/frontend-test-dag.json +4 -3
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -0
|
@@ -334,6 +334,41 @@ function asRecord(value) {
|
|
|
334
334
|
* Prefer exact schema payloads; otherwise map common discovery shapes onto the
|
|
335
335
|
* pytest-centric runtime contract without inventing secrets or managed commands.
|
|
336
336
|
*/
|
|
337
|
+
/** True when managed start is a local node/npm process the adapter cannot host. */
|
|
338
|
+
export function isLocalManagedServerStart(start) {
|
|
339
|
+
if (!start)
|
|
340
|
+
return false;
|
|
341
|
+
const normalized = start.trim().toLowerCase();
|
|
342
|
+
if (!normalized)
|
|
343
|
+
return false;
|
|
344
|
+
return (/\bnpm(\s+run)?\s+start\b/.test(normalized) ||
|
|
345
|
+
/\bnode\s+server(\.js)?\b/.test(normalized) ||
|
|
346
|
+
/\bnode\s+\.\/?server(\.js)?\b/.test(normalized) ||
|
|
347
|
+
/\bbash\s+scripts\/fe-test-server\.sh\b/.test(normalized) ||
|
|
348
|
+
normalized === "node server.js" ||
|
|
349
|
+
normalized.includes("server.js"));
|
|
350
|
+
}
|
|
351
|
+
function seedLocalServerFixture(fixtures, managedStart) {
|
|
352
|
+
if (Array.isArray(fixtures) && fixtures.length > 0) {
|
|
353
|
+
return fixtures.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item)));
|
|
354
|
+
}
|
|
355
|
+
if (managedStart.includes("server") || managedStart.includes("npm")) {
|
|
356
|
+
return [
|
|
357
|
+
{
|
|
358
|
+
name: "server-bootstrap",
|
|
359
|
+
sourcePath: "server.js",
|
|
360
|
+
kind: "server-bootstrap",
|
|
361
|
+
},
|
|
362
|
+
];
|
|
363
|
+
}
|
|
364
|
+
return [
|
|
365
|
+
{
|
|
366
|
+
name: "pytest-test-root",
|
|
367
|
+
sourcePath: BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT,
|
|
368
|
+
kind: "test-root",
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
}
|
|
337
372
|
/** Drop empty managedCommand strings; omit block when not managed-command. */
|
|
338
373
|
export function sanitizeBackendTestExecutionInput(value) {
|
|
339
374
|
const record = asRecord(value);
|
|
@@ -359,6 +394,51 @@ export function sanitizeBackendTestExecutionInput(value) {
|
|
|
359
394
|
next.managedCommand = cleaned;
|
|
360
395
|
}
|
|
361
396
|
}
|
|
397
|
+
// Adapter always executes `python -m pytest testcase/`. Scout-chosen relative roots such as
|
|
398
|
+
// tests/api/** are product sample trees, not the frozen automation root. Absolute / ..
|
|
399
|
+
// paths stay untouched so schema materialize remains fail-closed.
|
|
400
|
+
if (typeof next.testRoot === "string" && next.testRoot.trim()) {
|
|
401
|
+
const rawRoot = next.testRoot.trim();
|
|
402
|
+
const normalizedRoot = rawRoot.replace(/\/+$/, "");
|
|
403
|
+
const looksUnsafe = normalizedRoot.startsWith("/") ||
|
|
404
|
+
normalizedRoot.includes("..") ||
|
|
405
|
+
normalizedRoot.includes("\\") ||
|
|
406
|
+
/^[A-Za-z]:/.test(normalizedRoot);
|
|
407
|
+
if (!looksUnsafe &&
|
|
408
|
+
normalizedRoot !== BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT) {
|
|
409
|
+
const gaps = Array.isArray(next.evidenceGaps)
|
|
410
|
+
? [...next.evidenceGaps]
|
|
411
|
+
: [];
|
|
412
|
+
gaps.push({
|
|
413
|
+
description: `scout testRoot=${normalizedRoot} remapped to frozen adapter root ${BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT}`,
|
|
414
|
+
sourceRef: "adapter:backend-test-execution",
|
|
415
|
+
});
|
|
416
|
+
next.evidenceGaps = gaps;
|
|
417
|
+
next.testRoot = BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Local node/npm managed-command is not hosted by the clean-env pytest shell.
|
|
421
|
+
// Demote to in-process so generate-pytest must bootstrap the server inside fixtures
|
|
422
|
+
// instead of requiring host-injected base URL env vars that clean env cannot provide.
|
|
423
|
+
const managedStart = asRecord(next.managedCommand) &&
|
|
424
|
+
typeof asRecord(next.managedCommand).start === "string"
|
|
425
|
+
? String(asRecord(next.managedCommand).start)
|
|
426
|
+
: "";
|
|
427
|
+
if (next.targetMode === "managed-command" &&
|
|
428
|
+
isLocalManagedServerStart(managedStart)) {
|
|
429
|
+
next.targetMode = "in-process";
|
|
430
|
+
next.requiredEnvNames = [];
|
|
431
|
+
next.existingFixtures = seedLocalServerFixture(Array.isArray(next.existingFixtures) ? next.existingFixtures : null, managedStart);
|
|
432
|
+
const gaps = Array.isArray(next.evidenceGaps)
|
|
433
|
+
? [...next.evidenceGaps]
|
|
434
|
+
: [];
|
|
435
|
+
gaps.push({
|
|
436
|
+
description: "local managed-command demoted to in-process: clean-env pytest shell does not start npm/node servers or inject base URL env; tests must bootstrap via fixtures",
|
|
437
|
+
sourceRef: asRecord(next.managedCommand)?.sourceRef ||
|
|
438
|
+
"adapter:backend-test-execution",
|
|
439
|
+
});
|
|
440
|
+
next.evidenceGaps = gaps;
|
|
441
|
+
}
|
|
362
442
|
// Near-schema scouts sometimes emit in-process with existingFixtures: [].
|
|
363
443
|
// Only rewrite near-schema payloads here; free-form envelopes keep empty/missing
|
|
364
444
|
// fixtures so coerceBackendTestExecutionInput can map discoveredFixtures first.
|
|
@@ -369,23 +449,7 @@ export function sanitizeBackendTestExecutionInput(value) {
|
|
|
369
449
|
if (nearSchema &&
|
|
370
450
|
(next.targetMode === "in-process" || next.targetMode === undefined) &&
|
|
371
451
|
(!fixtures || fixtures.length === 0)) {
|
|
372
|
-
|
|
373
|
-
typeof asRecord(next.managedCommand).start === "string"
|
|
374
|
-
? String(asRecord(next.managedCommand).start)
|
|
375
|
-
: "";
|
|
376
|
-
next.existingFixtures = [
|
|
377
|
-
managedStart.includes("server")
|
|
378
|
-
? {
|
|
379
|
-
name: "server-bootstrap",
|
|
380
|
-
sourcePath: "server.js",
|
|
381
|
-
kind: "server-bootstrap",
|
|
382
|
-
}
|
|
383
|
-
: {
|
|
384
|
-
name: "pytest-test-root",
|
|
385
|
-
sourcePath: BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT,
|
|
386
|
-
kind: "test-root",
|
|
387
|
-
},
|
|
388
|
-
];
|
|
452
|
+
next.existingFixtures = seedLocalServerFixture(fixtures, managedStart);
|
|
389
453
|
if (next.targetMode === undefined)
|
|
390
454
|
next.targetMode = "in-process";
|
|
391
455
|
}
|
|
@@ -631,10 +695,12 @@ export function buildBackendTestExecutionPreflightShellSnippet(options) {
|
|
|
631
695
|
`console.log("backend-test preflight ok: framework=pytest testRoot="+testRoot+" targetMode="+contract.targetMode);'`,
|
|
632
696
|
' "${CONTRACT}"',
|
|
633
697
|
].join("");
|
|
698
|
+
// Fail-closed: join with && so a preflight exit 2 never continues into pytest.
|
|
699
|
+
// Historical ";" chains recorded I27-style false progresses (env missing + pytest ran).
|
|
634
700
|
return [
|
|
635
701
|
'test -n "${HARNESS_DAG_RUN_DIR:-}" || { echo "missing HARNESS_DAG_RUN_DIR for backend pytest preflight" >&2; exit 2; }',
|
|
636
702
|
`CONTRACT="\${HARNESS_DAG_RUN_DIR}/${contractRelativePath}"`,
|
|
637
703
|
'test -f "${CONTRACT}" || { echo "missing backend-test execution contract: ${CONTRACT}" >&2; exit 2; }',
|
|
638
704
|
nodePreflight,
|
|
639
|
-
].join("
|
|
705
|
+
].join(" && ");
|
|
640
706
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const STABILITY_EVIDENCE_SCHEMA_ID = "stability-evidence-v1";
|
|
3
|
+
const runRefSchema = z.object({
|
|
4
|
+
runId: z.string().min(1),
|
|
5
|
+
suiteId: z.string().min(1),
|
|
6
|
+
version: z.string().min(1),
|
|
7
|
+
commitSha: z.string().regex(/^[a-f0-9]{7,64}$/),
|
|
8
|
+
success: z.boolean(),
|
|
9
|
+
resultRef: z.string().min(1),
|
|
10
|
+
}).strict();
|
|
11
|
+
export const stabilityEvidenceSchema = z.object({
|
|
12
|
+
schemaVersion: z.literal(1),
|
|
13
|
+
schemaId: z.literal(STABILITY_EVIDENCE_SCHEMA_ID),
|
|
14
|
+
suiteId: z.string().min(1),
|
|
15
|
+
version: z.string().min(1),
|
|
16
|
+
commitSha: z.string().regex(/^[a-f0-9]{7,64}$/),
|
|
17
|
+
runs: z.array(runRefSchema),
|
|
18
|
+
recordedRuns: z.number().int().min(0),
|
|
19
|
+
successfulRuns: z.number().int().min(0),
|
|
20
|
+
failedRuns: z.number().int().min(0),
|
|
21
|
+
ratio: z.number().min(0).max(1).nullable(),
|
|
22
|
+
minimumRuns: z.literal(5),
|
|
23
|
+
status: z.enum(["available", "unavailable"]),
|
|
24
|
+
reason: z.string().min(1).nullable(),
|
|
25
|
+
}).strict().superRefine((value, ctx) => {
|
|
26
|
+
if (value.recordedRuns !== value.runs.length)
|
|
27
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "recordedRuns must equal runs.length", path: ["recordedRuns"] });
|
|
28
|
+
if (value.successfulRuns !== value.runs.filter((run) => run.success).length)
|
|
29
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "successfulRuns must match run facts", path: ["successfulRuns"] });
|
|
30
|
+
if (value.failedRuns !== value.runs.filter((run) => !run.success).length)
|
|
31
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "failedRuns must match run facts", path: ["failedRuns"] });
|
|
32
|
+
for (const [index, run] of value.runs.entries()) {
|
|
33
|
+
if (run.suiteId !== value.suiteId || run.version !== value.version || run.commitSha !== value.commitSha) {
|
|
34
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "all runs must bind to the same suite/version/commit", path: ["runs", index] });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (value.recordedRuns >= value.minimumRuns && value.ratio === null)
|
|
38
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ratio required when minimum sample is met", path: ["ratio"] });
|
|
39
|
+
});
|
|
40
|
+
export function buildStabilityEvidence(input) {
|
|
41
|
+
const recordedRuns = input.runs.length;
|
|
42
|
+
const successfulRuns = input.runs.filter((run) => run.success).length;
|
|
43
|
+
const failedRuns = recordedRuns - successfulRuns;
|
|
44
|
+
const status = recordedRuns >= 5 ? "available" : "unavailable";
|
|
45
|
+
return stabilityEvidenceSchema.parse({
|
|
46
|
+
schemaVersion: 1,
|
|
47
|
+
schemaId: STABILITY_EVIDENCE_SCHEMA_ID,
|
|
48
|
+
...input,
|
|
49
|
+
recordedRuns,
|
|
50
|
+
successfulRuns,
|
|
51
|
+
failedRuns,
|
|
52
|
+
ratio: recordedRuns >= 5 ? successfulRuns / recordedRuns : null,
|
|
53
|
+
minimumRuns: 5,
|
|
54
|
+
status,
|
|
55
|
+
reason: recordedRuns >= 5 ? null : "minimum-sample-size-not-met",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -81,27 +81,77 @@ export function resolveRunLocalPath(runDir, ref, label) {
|
|
|
81
81
|
}
|
|
82
82
|
return resolved;
|
|
83
83
|
}
|
|
84
|
+
function resolveCaseIdFromItem(item) {
|
|
85
|
+
if (typeof item === "object" &&
|
|
86
|
+
item !== null &&
|
|
87
|
+
typeof item.caseId === "string") {
|
|
88
|
+
return item.caseId;
|
|
89
|
+
}
|
|
90
|
+
return String(item);
|
|
91
|
+
}
|
|
92
|
+
async function materializeBlockedCaseEvidence(input) {
|
|
93
|
+
if (!input.workspaceRef)
|
|
94
|
+
return;
|
|
95
|
+
const workspace = resolveRunLocalPath(input.cwd, input.workspaceRef, "workspaceRef");
|
|
96
|
+
await mkdir(workspace, { recursive: true });
|
|
97
|
+
const resultPath = path.join(workspace, "case-result.json");
|
|
98
|
+
const caseId = resolveCaseIdFromItem(input.item);
|
|
99
|
+
try {
|
|
100
|
+
const existing = JSON.parse(await readFile(resultPath, "utf-8"));
|
|
101
|
+
if (existing.caseId === caseId &&
|
|
102
|
+
(existing.status === "passed" ||
|
|
103
|
+
existing.status === "failed" ||
|
|
104
|
+
existing.status === "blocked"))
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Missing or malformed evidence is replaced only for a child that never finished a business result.
|
|
109
|
+
}
|
|
110
|
+
await writeFile(path.join(workspace, "execution.md"), `# ${caseId}\n\nStatus: blocked\n\nReason: ${input.reason}\n`, "utf-8");
|
|
111
|
+
await writeFile(resultPath, `${JSON.stringify({
|
|
112
|
+
caseId,
|
|
113
|
+
status: "blocked",
|
|
114
|
+
blockedReason: input.reason,
|
|
115
|
+
evidencePaths: ["execution.md"],
|
|
116
|
+
}, null, 2)}\n`, "utf-8");
|
|
117
|
+
}
|
|
84
118
|
async function materializeTokenBudgetBlockedEvidence(input) {
|
|
119
|
+
await materializeBlockedCaseEvidence({
|
|
120
|
+
...input,
|
|
121
|
+
reason: "token-budget-exhausted",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async function materializeFailedCaseEvidence(input) {
|
|
85
125
|
if (!input.workspaceRef)
|
|
86
126
|
return;
|
|
87
127
|
const workspace = resolveRunLocalPath(input.cwd, input.workspaceRef, "workspaceRef");
|
|
88
128
|
await mkdir(workspace, { recursive: true });
|
|
89
129
|
const resultPath = path.join(workspace, "case-result.json");
|
|
90
|
-
const caseId =
|
|
91
|
-
typeof input.item.caseId === "string"
|
|
92
|
-
? input.item.caseId
|
|
93
|
-
: String(input.item);
|
|
130
|
+
const caseId = resolveCaseIdFromItem(input.item);
|
|
94
131
|
try {
|
|
95
132
|
const existing = JSON.parse(await readFile(resultPath, "utf-8"));
|
|
96
133
|
if (existing.caseId === caseId &&
|
|
97
|
-
(existing.status === "passed" ||
|
|
134
|
+
(existing.status === "passed" ||
|
|
135
|
+
existing.status === "failed" ||
|
|
136
|
+
existing.status === "blocked"))
|
|
98
137
|
return;
|
|
99
138
|
}
|
|
100
139
|
catch {
|
|
101
|
-
// Missing or malformed evidence is replaced only
|
|
140
|
+
// Missing or malformed evidence is replaced only when the child never finished a business result.
|
|
102
141
|
}
|
|
103
|
-
await writeFile(path.join(workspace, "execution.md"), `# ${caseId}\n\nStatus:
|
|
104
|
-
await writeFile(resultPath, `${JSON.stringify({
|
|
142
|
+
await writeFile(path.join(workspace, "execution.md"), `# ${caseId}\n\nStatus: failed\n\nReason: ${input.reason}\n`, "utf-8");
|
|
143
|
+
await writeFile(resultPath, `${JSON.stringify({
|
|
144
|
+
caseId,
|
|
145
|
+
status: "failed",
|
|
146
|
+
errorSummary: input.reason,
|
|
147
|
+
evidencePaths: ["execution.md"],
|
|
148
|
+
}, null, 2)}\n`, "utf-8");
|
|
149
|
+
}
|
|
150
|
+
/** Provider auth/timeout on a FE case child is infrastructure, not a product fail; keep the map aggregate. */
|
|
151
|
+
function isInfrastructureCaseChildFailure(record) {
|
|
152
|
+
if (!record || record.status === "FINISHED")
|
|
153
|
+
return false;
|
|
154
|
+
return record.failureCategory === "auth" || record.failureCategory === "timeout";
|
|
105
155
|
}
|
|
106
156
|
export async function executeDynamicMapExpansion(input) {
|
|
107
157
|
const started = Date.now();
|
|
@@ -264,15 +314,64 @@ export async function executeDynamicMapExpansion(input) {
|
|
|
264
314
|
tokenBudgetExhausted = true;
|
|
265
315
|
}
|
|
266
316
|
}
|
|
317
|
+
// Opt-in (frontend-test via tolerateChildFailures): materialize case-level
|
|
318
|
+
// failed/blocked evidence and keep the map barrier green so validate /
|
|
319
|
+
// result / retrospect can still run. Default maps stay fail-closed on child ERROR.
|
|
320
|
+
const tolerateChildFailures = input.expansion.tolerateChildFailures === true;
|
|
321
|
+
const caseOutcomeNotes = [];
|
|
322
|
+
if (tolerateChildFailures) {
|
|
323
|
+
for (let index = 0; index < childNodeIds.length; index += 1) {
|
|
324
|
+
const nodeId = childNodeIds[index];
|
|
325
|
+
const record = input.state.nodes[nodeId];
|
|
326
|
+
if (!record || record.status === "FINISHED")
|
|
327
|
+
continue;
|
|
328
|
+
if (isBudgetBlockedRecord(record))
|
|
329
|
+
continue;
|
|
330
|
+
const infra = isInfrastructureCaseChildFailure(record);
|
|
331
|
+
const reason = infra
|
|
332
|
+
? record?.failureCategory === "timeout"
|
|
333
|
+
? "executor-timeout"
|
|
334
|
+
: "executor-auth-unavailable"
|
|
335
|
+
: `executor-error:${record.failureCategory || record.status || "unknown"}`;
|
|
336
|
+
const outcomeStatus = infra ? "blocked" : "failed";
|
|
337
|
+
try {
|
|
338
|
+
if (outcomeStatus === "blocked") {
|
|
339
|
+
await materializeBlockedCaseEvidence({
|
|
340
|
+
cwd: input.cwd,
|
|
341
|
+
workspaceRef: workspaceRefs[index],
|
|
342
|
+
item: items[index],
|
|
343
|
+
reason,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
await materializeFailedCaseEvidence({
|
|
348
|
+
cwd: input.cwd,
|
|
349
|
+
workspaceRef: workspaceRefs[index],
|
|
350
|
+
item: items[index],
|
|
351
|
+
reason,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
throw new Error(`failed to materialize case outcome evidence for ${nodeId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
357
|
+
}
|
|
358
|
+
caseOutcomeNotes.push({ nodeId, reason, status: outcomeStatus });
|
|
359
|
+
record.status = "FINISHED";
|
|
360
|
+
record.stderr = `${outcomeStatus}: ${reason}`;
|
|
361
|
+
record.failureCategory = "success";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
267
364
|
const budgetBlockedIds = new Set(blockedChildren.map((entry) => entry.nodeId));
|
|
268
|
-
const failedChildren =
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
365
|
+
const failedChildren = tolerateChildFailures
|
|
366
|
+
? []
|
|
367
|
+
: childNodeIds.filter((nodeId) => {
|
|
368
|
+
const record = input.state.nodes[nodeId];
|
|
369
|
+
if (!record || record.status === "FINISHED")
|
|
370
|
+
return false;
|
|
371
|
+
if (budgetBlockedIds.has(nodeId) || isBudgetBlockedRecord(record))
|
|
372
|
+
return false;
|
|
373
|
+
return true;
|
|
374
|
+
});
|
|
276
375
|
const aggregate = {
|
|
277
376
|
workflowNodeId: input.expansion.workflowNodeId,
|
|
278
377
|
itemCount: items.length,
|
|
@@ -285,21 +384,29 @@ export async function executeDynamicMapExpansion(input) {
|
|
|
285
384
|
output: parseJsonFromText(input.state.nodes[nodeId]?.stdout),
|
|
286
385
|
assistantText: input.state.nodes[nodeId]?.assistantText,
|
|
287
386
|
blocked: blockedChildren.find((child) => child.nodeId === nodeId)?.reason ??
|
|
387
|
+
caseOutcomeNotes.find((child) => child.nodeId === nodeId && child.status === "blocked")
|
|
388
|
+
?.reason ??
|
|
288
389
|
(isBudgetBlockedRecord(input.state.nodes[nodeId])
|
|
289
390
|
? "token-budget-exhausted"
|
|
290
391
|
: undefined),
|
|
392
|
+
failed: caseOutcomeNotes.find((child) => child.nodeId === nodeId && child.status === "failed")
|
|
393
|
+
?.reason,
|
|
291
394
|
})),
|
|
292
395
|
tokensUsed: totalTokensUsed,
|
|
396
|
+
caseOutcomeNotes: caseOutcomeNotes.length > 0 ? caseOutcomeNotes : undefined,
|
|
293
397
|
};
|
|
294
398
|
return {
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
// Real child failures remain fail-closed even when later cases are budget-blocked.
|
|
399
|
+
// With tolerateChildFailures: barrier always succeeds; product outcomes live in
|
|
400
|
+
// case evidence + result materialization. Without it: real child failures fail-close.
|
|
298
401
|
ok: failedChildren.length === 0,
|
|
299
402
|
stdout: JSON.stringify(aggregate),
|
|
300
|
-
stderr:
|
|
301
|
-
? `
|
|
302
|
-
|
|
403
|
+
stderr: caseOutcomeNotes.length > 0
|
|
404
|
+
? `map children recorded as case outcomes: ${caseOutcomeNotes
|
|
405
|
+
.map((entry) => `${entry.nodeId}=${entry.status}:${entry.reason}`)
|
|
406
|
+
.join(", ")}`
|
|
407
|
+
: failedChildren.length > 0
|
|
408
|
+
? `dynamic map children failed: ${failedChildren.join(", ")}`
|
|
409
|
+
: "",
|
|
303
410
|
failureCategory: failedChildren.length > 0 ? "dynamic-expansion-child-failed" : "success",
|
|
304
411
|
durationMs: Date.now() - started,
|
|
305
412
|
};
|