@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.
@@ -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
- const managedStart = asRecord(next.managedCommand) &&
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 = typeof input.item === "object" && input.item !== null &&
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" || existing.status === "failed" || existing.status === "blocked"))
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 for a child that never started.
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: blocked\n\nReason: token-budget-exhausted\n`, "utf-8");
104
- await writeFile(resultPath, `${JSON.stringify({ caseId, status: "blocked", blockedReason: "token-budget-exhausted", evidencePaths: ["execution.md"] }, null, 2)}\n`, "utf-8");
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 = childNodeIds.filter((nodeId) => {
269
- const record = input.state.nodes[nodeId];
270
- if (!record || record.status === "FINISHED")
271
- return false;
272
- if (budgetBlockedIds.has(nodeId) || isBudgetBlockedRecord(record))
273
- return false;
274
- return true;
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
- // A post-case token stop is an expected bounded outcome; downstream review
296
- // must receive its aggregate rather than be skipped with the map barrier.
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: failedChildren.length > 0
301
- ? `dynamic map children failed: ${failedChildren.join(", ")}`
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
  };