@tea-agent/loop-agent 0.29.3 → 0.31.0

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 (34) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/executors/dag-pi-executor.js +18 -6
  3. package/dist/worker/observability/dag-execution-trajectory.js +591 -0
  4. package/dist/worker/observability/read-model.js +258 -31
  5. package/dist/worker/observe/dag-node-execution-output.js +180 -0
  6. package/dist/worker/observe/routes.js +53 -6
  7. package/dist/worker/observe/static/dag-edge-routing.js +368 -0
  8. package/dist/worker/observe/static/dag-history-labels.js +95 -0
  9. package/dist/worker/observe/static/dag-layout.d.ts +12 -7
  10. package/dist/worker/observe/static/dag-layout.js +101 -21
  11. package/dist/worker/observe/static/favicon.svg +37 -0
  12. package/dist/worker/observe/static/format.js +31 -1
  13. package/dist/worker/observe/static/index.html +1 -1
  14. package/dist/worker/observe/static/state.js +102 -0
  15. package/dist/worker/observe/static/styles.css +267 -7
  16. package/dist/worker/observe/static/views/dag-graph.js +414 -154
  17. package/dist/worker/observe/static/views/dag-inspector.js +478 -27
  18. package/dist/worker/observe/static/views/dag-trajectory.js +313 -0
  19. package/dist/worker/observe/static/views/dag.js +20 -3
  20. package/dist/workflows/dag/backend-test-writer-completeness.js +398 -26
  21. package/dist/workflows/dag/dynamic-runtime/map.js +10 -4
  22. package/dist/workflows/dag/init-hybrid.js +228 -229
  23. package/dist/workflows/dag/node-execution.js +13 -2
  24. package/dist/workflows/dag/types.js +21 -7
  25. package/docs/architecture/README.md +4 -0
  26. package/docs/architecture/dag-execution.md +1 -1
  27. package/docs/architecture/worker-and-feature.md +1 -1
  28. package/docs/governance/README.md +3 -0
  29. package/docs/operations/README.md +1 -0
  30. package/docs/templates/backend-test-dag.json +664 -460
  31. package/docs/templates/frontend-test-dag.json +1 -1
  32. package/harness.json +2 -2
  33. package/package.json +3 -2
  34. package/scripts/kb-bootstrap-init-skeleton.sh +1 -1
@@ -5,11 +5,23 @@ import { z } from "zod";
5
5
  /**
6
6
  * Maps a backend-test generation writer task id to its writer-progress role.
7
7
  * Returns undefined for nodes that are not completeness-gated generators.
8
+ *
9
+ * Recognizes both the legacy single-node literal ids
10
+ * (`generate-backend-md-cases-pi` / `generate-backend-pytest-pi`) and the
11
+ * map_agent sharded child ids (`generate-backend-md-case-0001` /
12
+ * `generate-backend-pytest-case-0001`). The literal fallback keeps hand-
13
+ * written test DAGs that bypass buildHybridDagFromTask green.
8
14
  */
9
15
  export function backendTestWriterProgressRoleForTask(taskId) {
10
- if (taskId === "generate-backend-md-cases-pi")
16
+ if (!taskId)
17
+ return undefined;
18
+ if (taskId === "generate-backend-md-cases-pi" ||
19
+ taskId === "generate-backend-md-plan-pi" ||
20
+ taskId.startsWith("generate-backend-md-case-"))
11
21
  return "md-generate";
12
- if (taskId === "generate-backend-pytest-pi")
22
+ if (taskId === "generate-backend-pytest-pi" ||
23
+ taskId === "generate-backend-pytest-plan-pi" ||
24
+ taskId.startsWith("generate-backend-pytest-case-"))
13
25
  return "pytest-generate";
14
26
  return undefined;
15
27
  }
@@ -96,7 +108,13 @@ function looksLikeValidModuleStem(raw) {
96
108
  return false;
97
109
  return true;
98
110
  }
99
- function extractModuleStemsFromReadme(readme) {
111
+ /**
112
+ * Exported for reuse by the manifest-shell node that materializes the
113
+ * module list JSON consumed by the map_agent expansion. Kept deterministic
114
+ * (same extractor as the completeness gate) so the shard set always matches
115
+ * the README index the gate trusts.
116
+ */
117
+ export function extractModuleStemsFromReadme(readme) {
100
118
  const stems = [];
101
119
  // Only trust testcase/md/<stem>.md mentions that appear inside markdown
102
120
  // table rows (`| ... testcase/md/x.md ... |`) or as canonical relative
@@ -135,21 +153,94 @@ async function listMarkdownModules(workspaceRoot) {
135
153
  entry.name.toLowerCase() !== "readme.md")
136
154
  .map((entry) => path.posix.join("testcase/md", entry.name.replaceAll("\\", "/"))));
137
155
  }
156
+ /**
157
+ * Recursively list `.py` file paths relative to `dirRoot` (forward slashes).
158
+ * Used by the pytest plan-node completeness check to discover shared
159
+ * helpers/factories resources.
160
+ */
161
+ async function listPythonFiles(dirRoot) {
162
+ if (!(await exists(dirRoot)))
163
+ return [];
164
+ const out = [];
165
+ const walk = async (rel) => {
166
+ const absolute = path.join(dirRoot, rel);
167
+ const entries = await readdir(absolute, { withFileTypes: true });
168
+ for (const entry of entries) {
169
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
170
+ if (entry.isDirectory()) {
171
+ await walk(childRel);
172
+ }
173
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".py")) {
174
+ out.push(childRel.replaceAll("\\", "/"));
175
+ }
176
+ }
177
+ };
178
+ await walk("");
179
+ return orderedUnique(out);
180
+ }
181
+ const MODULE_REQUIRED_SECTIONS = [
182
+ "\u8986\u76d6\u89c4\u5219",
183
+ "\u6d4b\u8bd5\u70b9",
184
+ "\u573a\u666f\u7c7b\u578b",
185
+ "\u524d\u7f6e\u6761\u4ef6",
186
+ "\u64cd\u4f5c\u6b65\u9aa4",
187
+ "\u9884\u671f\u7ed3\u679c",
188
+ "\u81ea\u52a8\u5316\u6620\u5c04",
189
+ ];
190
+ /**
191
+ * Structural check for a backend-test Markdown module file. Returns structured
192
+ * reasons instead of a bare boolean so the Completeness Gate T5 detail and the
193
+ * retry prompt can tell the model exactly what is wrong (empty file / wrong
194
+ * case heading level / invalid case id format / missing sections). This avoids
195
+ * the "model cannot fix what it does not know" retry death-loop.
196
+ */
138
197
  function moduleStructurallyComplete(markdown) {
139
- if (!markdown.trim())
140
- return false;
141
- if (!/^##\s+BE-[A-Z0-9_-]+-\d{2,3}\b/m.test(markdown))
142
- return false;
143
- const required = [
144
- "\u8986\u76d6\u89c4\u5219",
145
- "\u6d4b\u8bd5\u70b9",
146
- "\u573a\u666f\u7c7b\u578b",
147
- "\u524d\u7f6e\u6761\u4ef6",
148
- "\u64cd\u4f5c\u6b65\u9aa4",
149
- "\u9884\u671f\u7ed3\u679c",
150
- "\u81ea\u52a8\u5316\u6620\u5c04"
151
- ];
152
- return required.every((heading) => markdown.includes(`### ${heading}`));
198
+ if (!markdown.trim()) {
199
+ return { ok: false, reasons: ["file is empty"] };
200
+ }
201
+ const h2CaseMatch = /^##\s+BE-[A-Z0-9_-]+-\d{2,3}\b/m.exec(markdown);
202
+ if (!h2CaseMatch) {
203
+ // Distinguish "wrong level (### or ####)" from "invalid format / no case at all"
204
+ // so the retry hint is actionable.
205
+ const wrongLevel = /^#{3,4}\s+BE-[A-Z0-9_-]+-\d{2,3}\b/m.exec(markdown);
206
+ if (wrongLevel) {
207
+ const hashes = /^#+/.exec(wrongLevel[0])?.[0].length ?? 3;
208
+ return {
209
+ ok: false,
210
+ reasons: [
211
+ `case heading at wrong level: found '${hashes > 3 ? "####" : "###"} ${wrongLevel[0].replace(/^#+\s+/, "")}', require exactly 2 hashes '## BE-<MODULE>-<NNN>'`,
212
+ ],
213
+ };
214
+ }
215
+ const anyCaseId = /^#{2,4}\s+BE-[A-Z0-9_-]+-\d{1,4}/m.exec(markdown);
216
+ if (anyCaseId) {
217
+ return {
218
+ ok: false,
219
+ reasons: [
220
+ `invalid case heading format: '${anyCaseId[0].replace(/^#+\s+/, "")}' (need exactly 2-3 digit zero-padded NNN, e.g. BE-MOD-001)`,
221
+ ],
222
+ };
223
+ }
224
+ return {
225
+ ok: false,
226
+ reasons: [
227
+ "no h2 case heading found (require exactly '## BE-<MODULE>-<NNN>')",
228
+ ],
229
+ };
230
+ }
231
+ const missing = MODULE_REQUIRED_SECTIONS.filter((heading) => !markdown.includes(`### ${heading}`));
232
+ if (missing.length > 0) {
233
+ return {
234
+ ok: false,
235
+ reasons: [`missing required sections: [${missing.join(", ")}]`],
236
+ };
237
+ }
238
+ return { ok: true, reasons: [] };
239
+ }
240
+ function moduleStructuralDetail(result) {
241
+ if (result.ok)
242
+ return "module file is structurally complete";
243
+ return `module file is empty or has structural issues: ${result.reasons.join("; ")}`;
153
244
  }
154
245
  function pythonParseable(source) {
155
246
  const text = source.replace(/\r\n/g, "\n");
@@ -180,6 +271,27 @@ function pythonParseable(source) {
180
271
  // legal multi-line definitions such as `def f(\n x,\n):` — removed.
181
272
  return true;
182
273
  }
274
+ /**
275
+ * Looser Python validity check for shared helpers/factories emitted by the
276
+ * pytest plan node. Unlike pythonParseable, it does NOT require balanced
277
+ * brackets/braces: shared pytest resources legitimately contain fixture
278
+ * strings with unbalanced delimiters (e.g. a malformed-JSON test payload
279
+ * `return "{bad json"`). Only verifies the file is non-empty, declares a
280
+ * Python construct (def/class/import/from), and has balanced triple quotes.
281
+ */
282
+ function looksLikeValidPythonModule(source) {
283
+ const text = source.replace(/\r\n/g, "\n");
284
+ if (!text.trim())
285
+ return false;
286
+ if (/^\s*(?:def|class|import|from)\b/m.test(text) === false)
287
+ return false;
288
+ if (/("""|''')[\s\S]*$/.test(text)) {
289
+ const triples = text.match(/("""|''')/g) ?? [];
290
+ if (triples.length % 2 !== 0)
291
+ return false;
292
+ }
293
+ return true;
294
+ }
183
295
  export function buildOutputLimitRecoveryPrompt(input) {
184
296
  return buildOutputLimitRecoverySection(input);
185
297
  }
@@ -189,19 +301,33 @@ export function buildOutputLimitRecoveryPrompt(input) {
189
301
  * not depend on reading `.harness/**` (which is forbidden for Pi writers).
190
302
  */
191
303
  export function buildOutputLimitRecoverySection(input) {
192
- const paths = input.targetPaths.length > 0
193
- ? input.targetPaths.map((item) => ` - ${item}`).join("\n")
194
- : " - (none)";
304
+ const hasDiagnostics = !!input.diagnostics && Object.keys(input.diagnostics).length > 0;
305
+ const pathsBlock = hasDiagnostics
306
+ ? input.targetPaths
307
+ .map((p) => {
308
+ const reasons = (input.diagnostics?.[p] ?? []).slice(0, 3);
309
+ if (reasons.length === 0)
310
+ return ` - ${p}`;
311
+ const issueLines = reasons.map((r) => ` - ${r}`).join("\n");
312
+ return ` - ${p}\n issues:\n${issueLines}`;
313
+ })
314
+ .join("\n")
315
+ : input.targetPaths.length > 0
316
+ ? input.targetPaths.map((item) => ` - ${item}`).join("\n")
317
+ : " - (none)";
195
318
  return [
196
319
  "<retry_instruction>",
197
320
  `OUTPUT_LIMIT_RECOVERY attempt=${input.attempt}/${input.maxAttempts}`,
198
321
  `reason=${input.reason}`,
199
322
  "target_paths_only:",
200
- paths,
323
+ pathsBlock,
201
324
  "Rules:",
202
325
  "- Continue or repair ONLY listed paths; do not delete or shrink in-scope cases/rules/TPs.",
203
326
  "- One file per write/edit; no chat dump of full bodies.",
204
327
  "- Prefer edit/append for truncated files; rewrite a file only if unparseable or empty.",
328
+ ...(hasDiagnostics
329
+ ? ["- Fix the exact structural issues listed under each path; do not change unrelated content."]
330
+ : []),
205
331
  "- After targets are complete, short IMPLEMENTATION_OUTCOME only.",
206
332
  "- Do not mark already-satisfied if any target_path still missing or structurally broken.",
207
333
  "- Preserve quality invariants: no scope shrink, no skip/xfail, no secret-shaped samples.",
@@ -228,6 +354,85 @@ export async function loadBackendTestWriterProgressForRetry(runDir, taskId) {
228
354
  return undefined;
229
355
  }
230
356
  }
357
+ /**
358
+ * Per-shard-child completeness assessment for map_agent children
359
+ * (generate-backend-md-case-NNNN / generate-backend-pytest-case-NNNN).
360
+ *
361
+ * Unlike the workspace-wide {@link assessBackendTestMdWriterCompleteness} /
362
+ * {@link assessBackendTestPytestWriterCompleteness} gates (which run for the
363
+ * legacy single-node writers and for the post-map review/validate nodes), a
364
+ * sharded child owns ONLY the single file in its rendered writeSet. Running
365
+ * the workspace-wide gate mid-map would flag every not-yet-written sibling
366
+ * module as a recoverable miss and loop. This function scopes the gate to the
367
+ * child's own target file(s): md children are checked for structural
368
+ * completeness, pytest children for parseability. Sibling modules are ignored.
369
+ */
370
+ export async function assessBackendTestShardChildCompleteness(input) {
371
+ const role = input.role;
372
+ const targetPaths = input.writeSet
373
+ .map((entry) => entry.trim().replace(/\\/g, "/").replace(/^\.\//, ""))
374
+ .filter((entry) => entry.length > 0);
375
+ const issues = [];
376
+ const actualPaths = [];
377
+ const missingPaths = [];
378
+ const brokenPaths = [];
379
+ for (const target of targetPaths) {
380
+ // Rendered map-child writeSet entries are concrete (e.g.
381
+ // testcase/md/health.md / testcase/test_orders.py); glob-only entries are
382
+ // not produced for these children.
383
+ if (target.includes("*"))
384
+ continue;
385
+ const absolute = path.join(input.workspaceRoot, target);
386
+ if (!(await exists(absolute))) {
387
+ missingPaths.push(target);
388
+ issues.push({
389
+ code: "T3",
390
+ path: target,
391
+ detail: `shard child target ${target} was not written`,
392
+ recoverable: true,
393
+ });
394
+ continue;
395
+ }
396
+ actualPaths.push(target);
397
+ const body = await readFile(absolute, "utf8");
398
+ if (role === "md-generate") {
399
+ const structural = moduleStructurallyComplete(body);
400
+ if (!structural.ok) {
401
+ brokenPaths.push(target);
402
+ issues.push({
403
+ code: "T5",
404
+ path: target,
405
+ detail: moduleStructuralDetail(structural),
406
+ recoverable: true,
407
+ });
408
+ }
409
+ }
410
+ else {
411
+ if (!pythonParseable(body)) {
412
+ brokenPaths.push(target);
413
+ issues.push({
414
+ code: "T5",
415
+ path: target,
416
+ detail: "pytest script appears truncated or unparseable",
417
+ recoverable: true,
418
+ });
419
+ }
420
+ }
421
+ }
422
+ const recoverableTargets = orderedUnique([...missingPaths, ...brokenPaths]);
423
+ const status = recoverableTargets.length > 0 || issues.length > 0 ? "RECOVERABLE" : "PASS";
424
+ return {
425
+ schemaId: BACKEND_TEST_WRITER_PROGRESS_SCHEMA_ID,
426
+ role,
427
+ status,
428
+ expectedPaths: orderedUnique(targetPaths),
429
+ actualPaths: orderedUnique(actualPaths),
430
+ missingPaths: orderedUnique(missingPaths),
431
+ brokenPaths: orderedUnique(brokenPaths),
432
+ targetPaths: recoverableTargets,
433
+ issues,
434
+ };
435
+ }
231
436
  export async function assessBackendTestMdWriterCompleteness(workspaceRoot) {
232
437
  const issues = [];
233
438
  const expectedPaths = ["testcase/md/README.md"];
@@ -292,12 +497,13 @@ export async function assessBackendTestMdWriterCompleteness(workspaceRoot) {
292
497
  }
293
498
  actualPaths.push(existing);
294
499
  const body = await readFile(path.join(workspaceRoot, existing), "utf8");
295
- if (!moduleStructurallyComplete(body)) {
500
+ const structural = moduleStructurallyComplete(body);
501
+ if (!structural.ok) {
296
502
  brokenPaths.push(existing);
297
503
  issues.push({
298
504
  code: "T5",
299
505
  path: existing,
300
- detail: "module file is empty or missing required case sections",
506
+ detail: moduleStructuralDetail(structural),
301
507
  recoverable: true,
302
508
  });
303
509
  }
@@ -306,12 +512,13 @@ export async function assessBackendTestMdWriterCompleteness(workspaceRoot) {
306
512
  if (!actualPaths.includes(rel))
307
513
  actualPaths.push(rel);
308
514
  const body = await readFile(path.join(workspaceRoot, rel), "utf8");
309
- if (!moduleStructurallyComplete(body) && !brokenPaths.includes(rel)) {
515
+ const structural = moduleStructurallyComplete(body);
516
+ if (!structural.ok && !brokenPaths.includes(rel)) {
310
517
  brokenPaths.push(rel);
311
518
  issues.push({
312
519
  code: "T5",
313
520
  path: rel,
314
- detail: "module file is empty or missing required case sections",
521
+ detail: moduleStructuralDetail(structural),
315
522
  recoverable: true,
316
523
  });
317
524
  }
@@ -498,5 +705,170 @@ export function isBackendTestCompletenessRetryCandidate(task) {
498
705
  return false;
499
706
  }
500
707
  return (task.id === "generate-backend-md-cases-pi" ||
501
- task.id === "generate-backend-pytest-pi");
708
+ task.id === "generate-backend-pytest-pi" ||
709
+ task.id === "generate-backend-md-plan-pi" ||
710
+ task.id === "generate-backend-pytest-plan-pi" ||
711
+ (task.id?.startsWith("generate-backend-md-case-") ?? false) ||
712
+ (task.id?.startsWith("generate-backend-pytest-case-") ?? false));
713
+ }
714
+ /**
715
+ * Is this task a materialized map_agent shard child (as opposed to the legacy
716
+ * single-node generator)? Shard children must scope the Completeness Gate to
717
+ * their own rendered writeSet rather than the whole workspace.
718
+ */
719
+ export function isBackendTestShardChildTask(task) {
720
+ return Boolean(task.id?.startsWith("generate-backend-md-case-") ||
721
+ task.id?.startsWith("generate-backend-pytest-case-"));
722
+ }
723
+ /**
724
+ * Is this task the README-only plan node of the map-sharded md topology?
725
+ * The plan node writes testcase/md/README.md alone; module case cards are
726
+ * produced by the downstream map_agent children, so the Completeness Gate
727
+ * must NOT require module files to exist at plan time.
728
+ */
729
+ export function isBackendTestMdPlanTask(task) {
730
+ return task.id === "generate-backend-md-plan-pi";
731
+ }
732
+ /**
733
+ * Is this task the shared-resources plan node of the map-sharded pytest
734
+ * topology? The plan node writes helpers/factories only; per-module pytest
735
+ * scripts are produced by the downstream map_agent children, so the Gate
736
+ * must NOT require mapped pytest scripts to exist at plan time.
737
+ */
738
+ export function isBackendTestPytestPlanTask(task) {
739
+ return task.id === "generate-backend-pytest-plan-pi";
740
+ }
741
+ /**
742
+ * Plan-node completeness for the md topology: verify the README itself is
743
+ * structurally complete (Coverage Scope, Coverage Matrix, a non-empty module
744
+ * index), WITHOUT requiring the indexed module files to exist yet — those are
745
+ * written by the downstream map_agent shard children.
746
+ */
747
+ export async function assessBackendTestMdPlanCompleteness(workspaceRoot) {
748
+ const issues = [];
749
+ const expectedPaths = ["testcase/md/README.md"];
750
+ const actualPaths = [];
751
+ const missingPaths = [];
752
+ const brokenPaths = [];
753
+ const readmePath = path.join(workspaceRoot, "testcase", "md", "README.md");
754
+ if (!(await exists(readmePath))) {
755
+ missingPaths.push("testcase/md/README.md");
756
+ issues.push({
757
+ code: "T3",
758
+ path: "testcase/md/README.md",
759
+ detail: "README.md is missing",
760
+ recoverable: true,
761
+ });
762
+ }
763
+ else {
764
+ actualPaths.push("testcase/md/README.md");
765
+ const readme = await readFile(readmePath, "utf8");
766
+ if (!readme.includes("## Coverage Scope")) {
767
+ brokenPaths.push("testcase/md/README.md");
768
+ issues.push({
769
+ code: "T5",
770
+ path: "testcase/md/README.md",
771
+ detail: "README.md is missing ## Coverage Scope",
772
+ recoverable: true,
773
+ });
774
+ }
775
+ if (!readme.includes("## Coverage Matrix") ||
776
+ !hasMarkdownTable(readme, "Rule Key")) {
777
+ if (!brokenPaths.includes("testcase/md/README.md")) {
778
+ brokenPaths.push("testcase/md/README.md");
779
+ }
780
+ issues.push({
781
+ code: "T5",
782
+ path: "testcase/md/README.md",
783
+ detail: "README.md Coverage Matrix table is missing or broken",
784
+ recoverable: true,
785
+ });
786
+ }
787
+ // The README must advertise a non-empty module index so the downstream
788
+ // manifest shell has stems to shard; the indexed module files themselves
789
+ // are produced by map_agent children and are NOT required here.
790
+ const indexedStems = extractModuleStemsFromReadme(readme);
791
+ if (indexedStems.length === 0) {
792
+ if (!brokenPaths.includes("testcase/md/README.md")) {
793
+ brokenPaths.push("testcase/md/README.md");
794
+ }
795
+ issues.push({
796
+ code: "T5",
797
+ path: "testcase/md/README.md",
798
+ detail: "README.md module index is empty; downstream map_agent has no stems to shard",
799
+ recoverable: true,
800
+ });
801
+ }
802
+ }
803
+ const targetPaths = orderedUnique([...missingPaths, ...brokenPaths]);
804
+ const status = targetPaths.length > 0 ? "RECOVERABLE" : "PASS";
805
+ return {
806
+ schemaId: BACKEND_TEST_WRITER_PROGRESS_SCHEMA_ID,
807
+ role: "md-generate",
808
+ status,
809
+ expectedPaths: orderedUnique(expectedPaths),
810
+ actualPaths: orderedUnique(actualPaths),
811
+ missingPaths: orderedUnique(missingPaths),
812
+ brokenPaths: orderedUnique(brokenPaths),
813
+ targetPaths,
814
+ issues,
815
+ };
816
+ }
817
+ /**
818
+ * Plan-node completeness for the pytest topology: verify the shared
819
+ * helpers/factories resources were produced and are Python-parseable, WITHOUT
820
+ * requiring per-module pytest scripts to exist yet — those are written by the
821
+ * downstream map_agent shard children.
822
+ */
823
+ export async function assessBackendTestPytestPlanCompleteness(workspaceRoot) {
824
+ const issues = [];
825
+ const expectedPaths = [];
826
+ const actualPaths = [];
827
+ const missingPaths = [];
828
+ const brokenPaths = [];
829
+ // Discover shared pytest resources under testcase/**/helpers/** and
830
+ // testcase/**/factories/** (the plan node writeSet globs).
831
+ const candidates = [];
832
+ for (const dir of ["testcase/helpers", "testcase/factories"]) {
833
+ const absolute = path.join(workspaceRoot, dir);
834
+ if (await exists(absolute)) {
835
+ for (const file of await listPythonFiles(absolute)) {
836
+ candidates.push(`${dir}/${file}`);
837
+ }
838
+ }
839
+ }
840
+ if (candidates.length === 0) {
841
+ issues.push({
842
+ code: "T3",
843
+ detail: "no shared helpers/factories were produced; pytest plan must emit at least one shared resource",
844
+ recoverable: true,
845
+ });
846
+ }
847
+ for (const rel of candidates) {
848
+ expectedPaths.push(rel);
849
+ actualPaths.push(rel);
850
+ const body = await readFile(path.join(workspaceRoot, rel), "utf8");
851
+ if (!looksLikeValidPythonModule(body)) {
852
+ brokenPaths.push(rel);
853
+ issues.push({
854
+ code: "T5",
855
+ path: rel,
856
+ detail: "shared pytest resource appears truncated or unparseable",
857
+ recoverable: true,
858
+ });
859
+ }
860
+ }
861
+ const targetPaths = orderedUnique([...missingPaths, ...brokenPaths]);
862
+ const status = targetPaths.length > 0 || issues.length > 0 ? "RECOVERABLE" : "PASS";
863
+ return {
864
+ schemaId: BACKEND_TEST_WRITER_PROGRESS_SCHEMA_ID,
865
+ role: "pytest-generate",
866
+ status,
867
+ expectedPaths: orderedUnique(expectedPaths),
868
+ actualPaths: orderedUnique(actualPaths),
869
+ missingPaths: orderedUnique(missingPaths),
870
+ brokenPaths: orderedUnique(brokenPaths),
871
+ targetPaths,
872
+ issues,
873
+ };
502
874
  }
@@ -27,6 +27,9 @@ export function buildExpandedChildTask(input) {
27
27
  forbiddenPaths: renderDynamicPatternList(child.forbiddenPaths, item, index, expansion.itemName) ?? [],
28
28
  writeSet: renderDynamicPatternList(child.writeSet, item, index, expansion.itemName),
29
29
  outputContract: child.outputContract,
30
+ writerOutcomePolicy: child.writerOutcomePolicy,
31
+ writeGuardPolicy: child.writeGuardPolicy,
32
+ retryPolicy: child.retryPolicy,
30
33
  static: child.executor === "static"
31
34
  ? { resultMarkdown: staticResult ?? "dynamic child completed" }
32
35
  : undefined,
@@ -305,11 +308,14 @@ export async function executeDynamicMapExpansion(input) {
305
308
  });
306
309
  const executedTokens = input.state.nodes[child.id]?.tokensUsed ?? 0;
307
310
  totalTokensUsed += executedTokens;
311
+ // Only a true total-budget ceiling exhausts the map and cascades SKIPPED
312
+ // onto remaining children. A single child exceeding maxTokensPerCase is
313
+ // a per-child concern (it still FINISHED/ERROR'd on its own); it must NOT
314
+ // starve sibling shards — otherwise the map-shard topology that gives
315
+ // each module an independent 16K budget degrades back to a shared pool.
308
316
  if (tokenBudget &&
309
- ((tokenBudget.maxTokensPerCase !== undefined &&
310
- executedTokens >= tokenBudget.maxTokensPerCase) ||
311
- (tokenBudget.maxTotalTokens !== undefined &&
312
- totalTokensUsed >= tokenBudget.maxTotalTokens))) {
317
+ tokenBudget.maxTotalTokens !== undefined &&
318
+ totalTokensUsed >= tokenBudget.maxTotalTokens) {
313
319
  tokenBudgetExhausted = true;
314
320
  }
315
321
  }