@tea-agent/loop-agent 0.29.2 → 0.30.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.
- package/CHANGELOG.md +56 -24
- package/bin/agent-worker.js +0 -0
- package/dist/commands/client-recovery.js +2 -10
- package/dist/commands/init.js +1 -1
- package/dist/executors/dag-pi-executor.js +15 -4
- package/dist/executors/pi-executor.js +5 -3
- package/dist/executors/pi-playwright-cli-tool.js +9 -14
- package/dist/executors/pi-sdk-executor.js +16 -0
- package/dist/shared/operator/capabilities.js +9 -9
- package/dist/shared/pi-retry-settings.js +23 -0
- package/dist/worker/console/chat/pi-runtime.js +167 -41
- package/dist/worker/console/chat/routes.js +21 -2
- package/dist/worker/console/chat/runtime-context.js +50 -8
- package/dist/worker/console/chat/tool-preview.js +90 -0
- package/dist/worker/console/chat/turn-process.js +178 -0
- package/dist/worker/console/chat/usage.js +144 -16
- package/dist/worker/console/night-aux-ticker.js +141 -0
- package/dist/worker/console/operation-runner.js +21 -4
- package/dist/worker/console/operator-user-error.js +12 -0
- package/dist/worker/console/recovery-cta.js +6 -6
- package/dist/worker/console/routes.js +61 -0
- package/dist/worker/console/server.js +7 -0
- package/dist/worker/console/static/assets/index-D9gnJn_l.js +29 -0
- package/dist/worker/console/static/assets/index-rajoXwkM.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/night-prepare-result.js +118 -0
- package/dist/worker/scheduler/clock-install/win32-schtasks.js +70 -15
- package/dist/workflows/dag/backend-test-writer-completeness.js +398 -26
- package/dist/workflows/dag/dynamic-runtime/map.js +10 -4
- package/dist/workflows/dag/frontend-test-case-checklist.js +3 -3
- package/dist/workflows/dag/init-hybrid.js +247 -239
- package/dist/workflows/dag/node-execution.js +13 -2
- package/dist/workflows/dag/types.js +21 -7
- package/docs/templates/backend-test-dag.json +664 -460
- package/docs/templates/frontend-test-case-checklist.md +1 -1
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.json +1 -1
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/init-managed-agents.md +6 -3
- package/harness.json +2 -2
- package/package.json +1 -1
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/playwright-cli-case-generator/SKILL.md +1 -1
- package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +0 -29
- package/dist/worker/console/static/assets/index-Yyn3ynVv.css +0 -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
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
"
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
(tokenBudget.maxTotalTokens !== undefined &&
|
|
312
|
-
totalTokensUsed >= tokenBudget.maxTotalTokens))) {
|
|
317
|
+
tokenBudget.maxTotalTokens !== undefined &&
|
|
318
|
+
totalTokensUsed >= tokenBudget.maxTotalTokens) {
|
|
313
319
|
tokenBudgetExhausted = true;
|
|
314
320
|
}
|
|
315
321
|
}
|
|
@@ -289,7 +289,7 @@ export async function validateFrontendCaseChecklist(input) {
|
|
|
289
289
|
const issues = [];
|
|
290
290
|
const caseIdRe = /^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;
|
|
291
291
|
const acIdRe = /^AC(?:-[A-Z0-9]+)+$/i;
|
|
292
|
-
const openRe = /playwright-cli\s+open\s+--browser=chrome\s
|
|
292
|
+
const openRe = /playwright-cli\s+open\s+--browser=chrome\s+https?:\/\/\S+/i;
|
|
293
293
|
const productionHostRe = /(^|[.-])(prod|production)([.-]|$)/i;
|
|
294
294
|
for (const raw of manifest.cases) {
|
|
295
295
|
const item = raw;
|
|
@@ -310,8 +310,8 @@ export async function validateFrontendCaseChecklist(input) {
|
|
|
310
310
|
issues.push({ ruleId: "case-path-mismatch", caseId: id, casePath, detail: `${casePath} must equal ${expectedPath}` });
|
|
311
311
|
const body = await readFile(absolute, "utf8");
|
|
312
312
|
if (!openRe.test(body))
|
|
313
|
-
issues.push({ ruleId: "open-prefix", caseId: id, casePath, detail: "missing playwright-cli open --browser=chrome
|
|
314
|
-
const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s
|
|
313
|
+
issues.push({ ruleId: "open-prefix", caseId: id, casePath, detail: "missing playwright-cli open --browser=chrome <absolute-url>" });
|
|
314
|
+
const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+(https?:\/\/\S+)/i);
|
|
315
315
|
if (match) {
|
|
316
316
|
try {
|
|
317
317
|
const url = new URL(match[1].replace(/[)\]},.\"'`]+$/, ""));
|