@molecule/api-ai-tools 1.6.6 → 1.8.1

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/dist/tools.js CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
- import { commandCutRun, CUT_AT_BUDGET, cutRunSentence } from './cut-run.js';
9
+ import { commandCutRun, CUT_AT_BUDGET, cutRunSentence, suiteRunExitCode } from './cut-run.js';
10
10
  import { addChangeSpan, changedSpan, countLines, renderEditExcerpt, renderSmallFile, } from './edit-excerpt.js';
11
11
  import { guardToolExecute, missingParamError, ToolInputError } from './input-normalizer.js';
12
12
  import { TOOL_SCHEMAS } from './schemas.js';
@@ -36,6 +36,14 @@ const outputFileByTask = new Map();
36
36
  * resolves the failing test's `file:line` against the wrong suite.
37
37
  */
38
38
  const runByTask = new Map();
39
+ /**
40
+ * Why a finished suite run's result carries no exit code: the code a pipeline reports is its last
41
+ * stage's, and a stage after the runner (or a code of 0 beside a failure in the tally) is not the
42
+ * run's verdict. Said in the result so the missing field does not read as an omission.
43
+ */
44
+ const FILTERED_EXIT_NOTE = 'No exit code is reported for this run: a stage after the test runner produced the pipeline’s ' +
45
+ 'code (or the code says 0 beside a failure in the tally), so read the pass/fail tally in the ' +
46
+ 'log instead.';
39
47
  /**
40
48
  * Task ids whose own-output file has already been looked for this process.
41
49
  * {@link outputFileByTask} is empty for every task after a restart — it is
@@ -52,8 +60,20 @@ const repeatingCauseLookMs = () => Number(process.env.MOL_AI_BG_CAUSE_LOOK_MS) |
52
60
  * Task ids a wait has already named a repeating cause for. The note is news once; a second wait
53
61
  * on the same run has nothing to add to it, and re-sending it would spend the executor's turns
54
62
  * telling it what it has already been told.
63
+ *
64
+ * Only a wait that ENDED EARLY on the finding lands here ({@link earlyReturnCause}); a lead does
65
+ * not, because the wait it was found in keeps looking — a run can go from a shared duration to a
66
+ * proven cause failing everything as the app goes down, and that is exactly the finding the wait
67
+ * must not sleep through.
55
68
  */
56
69
  const repeatingCauseNoted = new Set();
70
+ /**
71
+ * Task ids whose mid-run finding has already ridden a result as a note ({@link
72
+ * repeatingFailureNote} without an {@link earlyReturnCause}). Same rule as
73
+ * {@link repeatingCauseNoted} — news once, and the executor still has the ✘ rows in its context —
74
+ * but kept apart from it, because a task in this set keeps being looked at.
75
+ */
76
+ const midRunLeadNoted = new Set();
57
77
  /**
58
78
  * The shell command that stops a background task by pid: the pid the platform wrote when it
59
79
  * detached the run, plus every process under it.
@@ -209,7 +229,8 @@ function runningNote(clauses) {
209
229
  const joined = clauses.join('; ');
210
230
  return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
211
231
  }
212
- import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, emptyCallError, failureCausesNote, findPathArgs, findPatternArgs, fusedLine, htmlOutline, isCommandTimeout, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isSuiteRunCommand, isValidGlob, linesWithinSerialized, literalDirPrefix, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_HTML_INLINE_CHARS, MAX_OUTPUT_SIZE, MAX_READ_CALL_CHARS, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, searchTimeoutError, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
232
+ import { diagnosisTraceCommand } from './trace-artifacts.js';
233
+ import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, earlyReturnCause, emptyCallError, failureCausesNote, findPathArgs, findPatternArgs, fusedLine, htmlOutline, isCommandTimeout, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isSuiteRunCommand, isValidGlob, linesWithinSerialized, literalDirPrefix, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_HTML_INLINE_CHARS, MAX_OUTPUT_SIZE, MAX_READ_CALL_CHARS, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, searchTimeoutError, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPipedAfterRunner, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
213
234
  /**
214
235
  * How long exec_command waits for a command it backgrounded only because the
215
236
  * command asked for more than the tool's ceiling, before handing back a task id.
@@ -322,6 +343,40 @@ export function installedPackageWriteRefusal(path) {
322
343
  : '';
323
344
  return `Not written: ${path} is inside ${what} (node_modules). ${change}${restore}`;
324
345
  }
346
+ /** How many failing replacements one refusal names before it stops listing. */
347
+ const MAX_REPORTED_REPLACEMENT_FAILURES = 5;
348
+ /**
349
+ * The one refusal a batch `edit_file` gets, naming EVERY replacement that did not
350
+ * match — each with the same detail a lone failure gets (which replacement, the
351
+ * head of its old_string, where it diverges from the file).
352
+ *
353
+ * A batch is applied all-or-nothing, so one bad `old_string` means none of the
354
+ * others were applied either: they are not "the rest, which worked", they are
355
+ * unchecked work the model will otherwise re-send as-is. Naming one per round trip
356
+ * cost flash-m98 two full re-sends of a six-replacement batch — the second bad
357
+ * hunk was not reported until the first was fixed, 7 s later.
358
+ *
359
+ * @param path - The file the batch targets (nothing was written to it).
360
+ * @param total - How many replacements the batch carried.
361
+ * @param failures - Every failing replacement, in batch order.
362
+ * @returns The error message.
363
+ */
364
+ function batchFailureError(path, total, failures) {
365
+ // A single replacement keeps the message it has always had: "Replacement 1 of 1"
366
+ // is noise, and the failure of a lone edit is not a batch problem.
367
+ if (total === 1 && failures.length === 1)
368
+ return failures[0].message;
369
+ const shown = failures.slice(0, MAX_REPORTED_REPLACEMENT_FAILURES);
370
+ const omitted = failures.length - shown.length;
371
+ const blocks = shown.map((f) => `Replacement ${f.index} of ${total} failed` +
372
+ (f.start ? ` (its old_string starts "${f.start}")` : '') +
373
+ `:\n${f.message}`);
374
+ return (`edit_file (${path}): nothing was written — ${failures.length} of the ${total} replacements do not match the file, and the batch is all-or-nothing, so re-send it with every one of these fixed.\n\n` +
375
+ blocks.join('\n\n') +
376
+ (omitted > 0
377
+ ? `\n\n(${omitted} more replacement${omitted === 1 ? '' : 's'} also failed and are not shown.)`
378
+ : ''));
379
+ }
325
380
  /**
326
381
  * Build a complete set of AI agent tools bound to an execution backend.
327
382
  *
@@ -330,7 +385,7 @@ export function installedPackageWriteRefusal(path) {
330
385
  * @returns Array of AITool objects ready to pass to an AI provider
331
386
  */
332
387
  export function buildTools(backend, config) {
333
- const { include, exclude, pathGuards = true, symlinkGuards = false, redactSecrets: doRedact = true, blockDangerousCommands = false, blockCommand, execTimeoutMs = 120_000, commandBudgetMs, overCeilingGraceMs = OVER_CEILING_GRACE_MS, searchExcludedDirs, outlineLargeHtml, onAfterWrite, onFileDiff, onFileChange, } = config ?? {};
388
+ const { include, exclude, pathGuards = true, symlinkGuards = false, redactSecrets: doRedact = true, blockDangerousCommands = false, blockCommand, execTimeoutMs = 120_000, commandBudgetMs, overCeilingGraceMs = OVER_CEILING_GRACE_MS, searchExcludedDirs, outlineLargeHtml, excludedTestTitles, onAfterWrite, onFileDiff, onFileChange, } = config ?? {};
334
389
  const root = backend.projectRoot;
335
390
  // A host-filesystem backend with escape guards off is the combination the
336
391
  // audit flagged: `pathGuards` off lets `..` paths out of the workspace,
@@ -1080,6 +1135,13 @@ export function buildTools(backend, config) {
1080
1135
  // Replacements refused for changing the line break at old_string's boundary and
1081
1136
  // nothing else — reported by position, never applied (see below).
1082
1137
  const skipped = [];
1138
+ // Every replacement this batch refuses, collected rather than returned on the
1139
+ // first: a batch is atomic, so the other bad hunks are just as unreported, and
1140
+ // one-per-round-trip cost flash-m98 two full re-sends of a six-replacement
1141
+ // batch (the second bad hunk was not named until the first was fixed).
1142
+ const failures = [];
1143
+ /** How a failure names the replacement it is about: the head of its old_string. */
1144
+ const startOf = (text) => text.split('\n')[0].trim().slice(0, 80);
1083
1145
  // Where each applied replacement landed, in the coordinates of the
1084
1146
  // latest content — rendered into the result so the model sees the edit.
1085
1147
  let spans = [];
@@ -1112,9 +1174,12 @@ export function buildTools(backend, config) {
1112
1174
  // method access) or silently inserts the literal "undefined" (a missing
1113
1175
  // new_string). Fail fast with an actionable message instead.
1114
1176
  if (typeof oldString !== 'string' || oldString === '' || typeof newString !== 'string') {
1115
- return {
1116
- error: `edit_file: each replacement needs a non-empty string old_string and a string new_string (got old_string: ${oldString === '' ? 'empty' : typeof oldString}, new_string: ${typeof newString}). To delete text, pass new_string: "".`,
1117
- };
1177
+ failures.push({
1178
+ index: index + 1,
1179
+ start: typeof oldString === 'string' ? startOf(oldString) : '',
1180
+ message: `each replacement needs a non-empty string old_string and a string new_string (got old_string: ${oldString === '' ? 'empty' : typeof oldString}, new_string: ${typeof newString}). To delete text, pass new_string: "".`,
1181
+ });
1182
+ continue;
1118
1183
  }
1119
1184
  const count = content.split(oldString).length - 1;
1120
1185
  // The edit is ALREADY in the file: old_string is gone and a distinctive
@@ -1136,9 +1201,12 @@ export function buildTools(backend, config) {
1136
1201
  // match failed and hand back the file's actual content so it fixes in
1137
1202
  // one retry instead of many.
1138
1203
  if (oldString.includes('\\n')) {
1139
- return {
1140
- error: `old_string not found in ${path} (hint: old_string contains literal "\\n" — use actual newlines in JSON strings, not escaped \\n)`,
1141
- };
1204
+ failures.push({
1205
+ index: index + 1,
1206
+ start: startOf(oldString),
1207
+ message: `old_string not found in ${path} (hint: old_string contains literal "\\n" — use actual newlines in JSON strings, not escaped \\n)`,
1208
+ });
1209
+ continue;
1142
1210
  }
1143
1211
  // Whitespace-only mismatch: the text is there but indentation/spacing
1144
1212
  // differs (tabs vs spaces, trailing whitespace). If a UNIQUE line-run
@@ -1154,9 +1222,12 @@ export function buildTools(backend, config) {
1154
1222
  const normalize = (s) => s.replace(/\s+/g, ' ').trim();
1155
1223
  const normOld = normalize(oldString);
1156
1224
  if (normOld && normalize(content).includes(normOld)) {
1157
- return {
1158
- error: `old_string not found in ${path} — a match exists but whitespace/indentation differs and is not unique enough to apply automatically. Re-read the file and copy the exact text (tabs vs spaces, trailing spaces, blank lines must match), or include more surrounding context.`,
1159
- };
1225
+ failures.push({
1226
+ index: index + 1,
1227
+ start: startOf(oldString),
1228
+ message: `old_string not found in ${path} — a match exists but whitespace/indentation differs and is not unique enough to apply automatically. Re-read the file and copy the exact text (tabs vs spaces, trailing spaces, blank lines must match), or include more surrounding context.`,
1229
+ });
1230
+ continue;
1160
1231
  }
1161
1232
  // Anchor probe: locate the target region by the most DISTINCTIVE line
1162
1233
  // of old_string (longest non-trivial line first, requiring a UNIQUE
@@ -1174,9 +1245,7 @@ export function buildTools(backend, config) {
1174
1245
  .slice(start, idx + 8)
1175
1246
  .map((l, i) => `${start + i + 1}: ${l}`)
1176
1247
  .join('\n');
1177
- return {
1178
- error: `old_string not found in ${path}. The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
1179
- };
1248
+ return `old_string not found in ${path}. The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`;
1180
1249
  };
1181
1250
  // An import block the model rebuilt in its own order: the linter's
1182
1251
  // import sort owns that order, so name it and show the real block. X1
@@ -1193,12 +1262,15 @@ export function buildTools(backend, config) {
1193
1262
  .slice(start, at + 1)
1194
1263
  .map((l, i) => `${start + i + 1}: ${l}`)
1195
1264
  .join('\n');
1196
- return {
1197
- error: `old_string not found in ${path}: it is an import block, and the linter's import ` +
1265
+ failures.push({
1266
+ index: index + 1,
1267
+ start: startOf(oldString),
1268
+ message: `old_string not found in ${path}: it is an import block, and the linter's import ` +
1198
1269
  `sort decides its order, so do not rebuild it. The file's import from ` +
1199
1270
  `'${imported}' reads:\n${block}\nEdit one line of it, or add your import as a ` +
1200
1271
  'new line and let the linter sort it.',
1201
- };
1272
+ });
1273
+ continue;
1202
1274
  }
1203
1275
  }
1204
1276
  const distinctiveLines = [
@@ -1233,13 +1305,11 @@ export function buildTools(backend, config) {
1233
1305
  .slice(start, base + k + 6)
1234
1306
  .map((l, i) => `${start + i + 1}: ${l}`)
1235
1307
  .join('\n');
1236
- return {
1237
- error: `old_string not found in ${path} — it matches the file through line ${base + k}, ` +
1238
- `then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
1239
- `Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
1240
- `The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
1241
- `The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
1242
- };
1308
+ return (`old_string not found in ${path} — it matches the file through line ${base + k}, ` +
1309
+ `then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
1310
+ `Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
1311
+ `The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
1312
+ `The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`);
1243
1313
  };
1244
1314
  // The longest run of old_string lines that IS in the file decides which
1245
1315
  // alignment to report, so a later distinctive line can beat an earlier
@@ -1271,10 +1341,22 @@ export function buildTools(backend, config) {
1271
1341
  if (k > 0 && k < oldLines.length && (best === null || k > best.k))
1272
1342
  best = { base, k };
1273
1343
  }
1274
- if (best)
1275
- return showDivergence(best.base, best.k);
1276
- if (firstUniqueHit !== null)
1277
- return showSnippet(firstUniqueHit);
1344
+ if (best) {
1345
+ failures.push({
1346
+ index: index + 1,
1347
+ start: startOf(oldString),
1348
+ message: showDivergence(best.base, best.k),
1349
+ });
1350
+ continue;
1351
+ }
1352
+ if (firstUniqueHit !== null) {
1353
+ failures.push({
1354
+ index: index + 1,
1355
+ start: startOf(oldString),
1356
+ message: showSnippet(firstUniqueHit),
1357
+ });
1358
+ continue;
1359
+ }
1278
1360
  // Fallback: the first non-blank line, even if not unique (better than a
1279
1361
  // bare "re-read" when the model just needs to see nearby real content).
1280
1362
  const firstLine = oldString
@@ -1283,8 +1365,14 @@ export function buildTools(backend, config) {
1283
1365
  ?.trim();
1284
1366
  if (firstLine) {
1285
1367
  const idx = fileLines.findIndex((l) => l.includes(firstLine));
1286
- if (idx >= 0)
1287
- return showSnippet(idx);
1368
+ if (idx >= 0) {
1369
+ failures.push({
1370
+ index: index + 1,
1371
+ start: startOf(oldString),
1372
+ message: showSnippet(idx),
1373
+ });
1374
+ continue;
1375
+ }
1288
1376
  }
1289
1377
  // Nothing anchored. Say WHICH of the two situations this is — they
1290
1378
  // call for different next moves, and the old message implied the
@@ -1294,20 +1382,20 @@ export function buildTools(backend, config) {
1294
1382
  // "re-read and copy the exact text", which is wrong advice when the
1295
1383
  // text is not in the file at all.
1296
1384
  const anchorsPresent = distinctiveLines.some((anchor) => fileLines.some((l) => l.includes(anchor)));
1297
- if (!anchorsPresent) {
1298
- return {
1299
- error: `old_string not found in ${path} — and NONE of its lines appear anywhere in ` +
1385
+ failures.push({
1386
+ index: index + 1,
1387
+ start: startOf(oldString),
1388
+ message: !anchorsPresent
1389
+ ? `old_string not found in ${path} — and NONE of its lines appear anywhere in ` +
1300
1390
  `that file, so this is not a whitespace problem. Either the edit was already ` +
1301
1391
  `applied, or this is the wrong file. read_file it and look before trying ` +
1302
- `again; do not retry the same old_string.`,
1303
- };
1304
- }
1305
- return {
1306
- error: `old_string not found in ${path}. Parts of it ARE in the file but no single ` +
1307
- `line is distinctive enough to locate the target. read_file the region you ` +
1308
- `mean and copy old_string verbatim from what it returns — including ` +
1309
- `indentation — or include more surrounding context to make it unique.`,
1310
- };
1392
+ `again; do not retry the same old_string.`
1393
+ : `old_string not found in ${path}. Parts of it ARE in the file but no single ` +
1394
+ `line is distinctive enough to locate the target. read_file the region you ` +
1395
+ `mean and copy old_string verbatim from what it returns — including ` +
1396
+ `indentation — or include more surrounding context to make it unique.`,
1397
+ });
1398
+ continue;
1311
1399
  }
1312
1400
  if (count > 1 && (replaceAll === true || replaceAllCamel === true)) {
1313
1401
  // Every occurrence, by split/join — never String.replace, whose
@@ -1323,9 +1411,12 @@ export function buildTools(backend, config) {
1323
1411
  const lines = [];
1324
1412
  for (let at = content.indexOf(oldString); at >= 0 && lines.length < 10; at = content.indexOf(oldString, at + 1))
1325
1413
  lines.push(content.slice(0, at).split('\n').length);
1326
- return {
1327
- error: `old_string found ${count} times in ${path} (starting at lines ${lines.join(', ')}${count > lines.length ? ', …' : ''}) — must be unique. Include more surrounding context from the one you mean, or set replace_all: true on this replacement to change every occurrence.`,
1328
- };
1414
+ failures.push({
1415
+ index: index + 1,
1416
+ start: startOf(oldString),
1417
+ message: `old_string found ${count} times in ${path} (starting at lines ${lines.join(', ')}${count > lines.length ? ', …' : ''}) — must be unique. Include more surrounding context from the one you mean, or set replace_all: true on this replacement to change every occurrence.`,
1418
+ });
1419
+ continue;
1329
1420
  }
1330
1421
  // Splice by index rather than `content.replace(oldString, newString)`:
1331
1422
  // String.replace treats `$&`, `$$`, `` $` ``, `$'` in the REPLACEMENT as
@@ -1352,6 +1443,13 @@ export function buildTools(backend, config) {
1352
1443
  spans = addChangeSpan(spans, content, spliced);
1353
1444
  content = spliced;
1354
1445
  }
1446
+ // Every replacement was checked before this: a batch that refuses ANY of them
1447
+ // writes nothing, so the ones that DID match are just as unreported as the ones
1448
+ // that did not — and naming them one per round trip cost flash-m98 two full
1449
+ // re-sends of a six-replacement batch. Nothing is written here: the file on disk
1450
+ // is the original, and the model fixes all of them in one retry.
1451
+ if (failures.length)
1452
+ return { error: batchFailureError(path, replacements.length, failures) };
1355
1453
  // One line per refused replacement: what it would have done, and the two ways to
1356
1454
  // say it on purpose. The rest of the batch is applied — the second half of the pair
1357
1455
  // above (`comments.ts`'s third replacement, `articles.ts`'s own text edit) anchors
@@ -1687,7 +1785,7 @@ export function buildTools(backend, config) {
1687
1785
  }
1688
1786
  },
1689
1787
  async exec_command(input) {
1690
- const command = input.command;
1788
+ const rawCommand = input.command;
1691
1789
  const cwd = input.cwd ? resolve(input.cwd, 'cwd') : root;
1692
1790
  // A model that sent the command under another name (`cmd`, `script`, …)
1693
1791
  // has already been normalized by guardToolExecute; reaching here with
@@ -1697,21 +1795,32 @@ export function buildTools(backend, config) {
1697
1795
  // undefined (reading 'match')" — the executor read that as a broken shell,
1698
1796
  // stopped verifying, and ended the turn claiming all checks passed while
1699
1797
  // seven acceptance checks failed (X0 rehearsal 83, 2026-09-16).
1700
- if (typeof command !== 'string' || command.trim() === '') {
1798
+ if (typeof rawCommand !== 'string' || rawCommand.trim() === '') {
1701
1799
  return { error: missingParamError(TOOL_SCHEMAS.exec_command, input, ['command']) };
1702
1800
  }
1703
1801
  if (blockDangerousCommands) {
1704
- const blocked = checkBlockedCommand(command);
1802
+ const blocked = checkBlockedCommand(rawCommand);
1705
1803
  if (blocked)
1706
1804
  return { error: blocked };
1707
1805
  }
1708
1806
  // Consumer-specific guard (see ToolBuildConfig.blockCommand) — an environment can
1709
1807
  // veto commands the generic dangerous-command check can't know about.
1710
1808
  if (blockCommand) {
1711
- const blocked = blockCommand(command, cwd);
1809
+ const blocked = blockCommand(rawCommand, cwd);
1712
1810
  if (blocked)
1713
1811
  return { error: blocked };
1714
1812
  }
1813
+ // A diagnosis run of a suite whose config records no trace gets one recorded anyway
1814
+ // (`diagnosisTraceCommand`, trace-artifacts.ts): the failing tests' own network log is what
1815
+ // tells a swallowed request error from a selector, and X1 xr-molecule-flash-m100 spent the
1816
+ // last minutes of its run hand-writing Chromium probes for what the trace it never wrote
1817
+ // down would have said. The whole rule is that function's; the proof run is never touched.
1818
+ const traced = await diagnosisTraceCommand({
1819
+ command: rawCommand,
1820
+ cwd,
1821
+ readFile: (path) => backend.readFile(path),
1822
+ });
1823
+ const command = traced.command;
1715
1824
  // Detached: start it, hand back a handle, let the turn continue. A
1716
1825
  // command that outlives the tool's ceiling is otherwise simply lost —
1717
1826
  // measured across six agent runs, ten of them were killed at the
@@ -1759,6 +1868,10 @@ export function buildTools(backend, config) {
1759
1868
  // Keep the log growing while it runs (see streamingBackgroundCommand):
1760
1869
  // an empty log is what wait_for_task reads as stuck.
1761
1870
  const streaming = streamingBackgroundCommand(command);
1871
+ // …and the trace flag this tool added (above), which the streaming rewrite never saw:
1872
+ // its changes are what this branch reports the run's own settings in.
1873
+ if (traced.change)
1874
+ streaming.changes.unshift(traced.change);
1762
1875
  // A whole-suite run starves the sandbox it shares with the app and with
1763
1876
  // every command the executor sends next, so it starts at lower CPU and
1764
1877
  // IO priority (see suiteRunPriorityLaunch). Foreground runs are never
@@ -1774,6 +1887,9 @@ export function buildTools(backend, config) {
1774
1887
  outputFileByTask.set(id, ownFile);
1775
1888
  runByTask.set(id, { command: streaming.command, cwd });
1776
1889
  await backend.writeFile(script, `${streaming.command}\n`);
1890
+ // The clock the grace return below reports a stopped run against: the command is
1891
+ // started here, and how long it ran is what a stop at its own bound is read from.
1892
+ const launchedAt = Date.now();
1777
1893
  await backend.run(`nohup sh -c ${shellQuote(prioritized.launch)} ` +
1778
1894
  `> /dev/null 2>&1 & echo $! > /tmp/${id}.pid`, { cwd, timeout: 15_000 });
1779
1895
  // A command backgrounded only for the budget it ASKED for often ends in
@@ -1803,12 +1919,43 @@ export function buildTools(backend, config) {
1803
1919
  // Finished without writing anything: an empty output is still a result.
1804
1920
  output = '';
1805
1921
  }
1922
+ const stdout = sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE));
1923
+ // A grace return is a foreground-shaped result, so it carries the same stop: this
1924
+ // command was started because its output is withheld until it ends (a Playwright
1925
+ // run behind a pipe), and a run that printed a tally accounting for fewer tests
1926
+ // than it announced was stopped — not "Finished … with exit code 0" (the pipeline's
1927
+ // 0 is the filter's). No tool budget applies to a detached run, so the command's
1928
+ // own `timeout` is the only bound here.
1929
+ const cut = commandCutRun(command, { exitCode: code, stdout, stderr: '' }, Date.now() - launchedAt, 0, output);
1930
+ if (cut) {
1931
+ return {
1932
+ stdout,
1933
+ stderr: '',
1934
+ status: CUT_AT_BUDGET,
1935
+ exitCode: cut.exitCode,
1936
+ cutRun: cut,
1937
+ ...(output.length > MAX_OUTPUT_SIZE
1938
+ ? { truncated: true, fullOutput: log }
1939
+ : {}),
1940
+ error: `${cutRunSentence(cut)} ` +
1941
+ (isSuiteRunCommand(command) ? SUITE_OVER_BUDGET_ADVICE : SPLIT_WORK_ADVICE),
1942
+ };
1943
+ }
1944
+ // The exit file holds the PIPELINE's code: when a stage this rewrite could not
1945
+ // prove is output-only still follows the suite runner (`| tee run.log`), that code
1946
+ // is the filter's — report none and let the log's tally decide (m101 +25:54).
1947
+ const piped = suiteRunPipedAfterRunner(streaming.command);
1948
+ const reported = suiteRunExitCode(code, output, piped);
1949
+ const notes = [
1950
+ ...streaming.changes,
1951
+ ...(reported === null ? [FILTERED_EXIT_NOTE] : []),
1952
+ ];
1806
1953
  return {
1807
- stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
1954
+ stdout,
1808
1955
  stderr: '',
1809
- exitCode: code,
1956
+ exitCode: reported,
1810
1957
  ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
1811
- ...(streaming.changes.length ? { note: streaming.changes.join(' ') } : {}),
1958
+ ...(notes.length ? { note: notes.join(' ') } : {}),
1812
1959
  };
1813
1960
  }
1814
1961
  await new Promise((r) => setTimeout(r, 1_500));
@@ -1900,7 +2047,12 @@ export function buildTools(backend, config) {
1900
2047
  // output and the clock in one place (cut-run.ts) so every reader of a run's verdict reads
1901
2048
  // the same state (X1 xr-molecule-flash-m93: `timeout 280 hurl … | tail -60` came back
1902
2049
  // `exitCode: 0` after 284 s and four of thirteen files, and the executor re-sent it).
1903
- const cut = commandCutRun(command, { exitCode: result.exitCode, stdout, stderr }, elapsedMs, budgetSeconds);
2050
+ const cut = commandCutRun(command, { exitCode: result.exitCode, stdout, stderr }, elapsedMs, budgetSeconds,
2051
+ // Detection reads the RAW output: `truncateMiddle` keeps the head and the tail, and the
2052
+ // runner's announcement (`Running 139 tests using 1 worker`) is the first line a long run
2053
+ // prints — the one a size cap drops first. Only the counts and the tally come from here;
2054
+ // what the result quotes is still the sanitized, truncated text.
2055
+ `${result.stdout}\n${result.stderr}`);
1904
2056
  if (cut) {
1905
2057
  const cutLead = cutRunSentence(cut);
1906
2058
  return {
@@ -1926,7 +2078,9 @@ export function buildTools(backend, config) {
1926
2078
  stdout,
1927
2079
  stderr,
1928
2080
  exitCode: result.exitCode,
1929
- ...(ceilingNote ? { note: ceilingNote } : {}),
2081
+ ...(ceilingNote || traced.change
2082
+ ? { note: [ceilingNote, traced.change].filter(Boolean).join(' ') }
2083
+ : {}),
1930
2084
  };
1931
2085
  }
1932
2086
  catch (e) {
@@ -2092,9 +2246,19 @@ export function buildTools(backend, config) {
2092
2246
  // and then fixed it in 51 s. The task is NOT stopped — the executor is handed the cause and
2093
2247
  // decides, and the note tells it to let the run finish because the run's other causes only
2094
2248
  // surface in it (X1 flash-m83 stopped eight runs at their first repeating cause and never
2095
- // finished one). Only a cause that is failing the WHOLE run — every failure sharing it,
2096
- // across `UNIVERSAL_CAUSE_FILES` files or more, with nothing passing since the first —
2097
- // comes with a stop command.
2249
+ // finished one).
2250
+ //
2251
+ // WHAT ENDS THIS WAIT is the whole of the executor's next move, so only ONE finding does:
2252
+ // `earlyReturnCause` — a PROVEN cause (an error repeating verbatim, never a shared duration)
2253
+ // that is failing the WHOLE run (every failure the log names, across `UNIVERSAL_CAUSE_FILES`
2254
+ // files or more, with nothing passing since the first). Anything else is a LEAD: the wait
2255
+ // keeps going to the caller's own timeout, and the finding is attached to the result of this
2256
+ // wait as a note. X1 xr-molecule-flash-m99 (2026-10-16) was cut short twice by a status that
2257
+ // returned early on three tests sharing the suite's own `timeout: 15_000` — a signature the
2258
+ // note it printed called "a lead, not a cause" — and read the early return as "stop waiting,
2259
+ // edit now", voiding a run that was still walking both times (the second wait, 224 s of a
2260
+ // 280 s budget, ended with nothing at all).
2261
+ let midRunNote = null;
2098
2262
  let lastCauseLookAt = startedAt - repeatingCauseLookMs();
2099
2263
  let exitCode = await readExit();
2100
2264
  while (exitCode === null && Date.now() - startedAt < budgetMs) {
@@ -2123,29 +2287,46 @@ export function buildTools(backend, config) {
2123
2287
  const found = repeatingFailure(causeLog);
2124
2288
  if (!found)
2125
2289
  continue;
2126
- // Only a cause that is failing the whole run gets a stop command — and only then is the
2127
- // pid walk paid for.
2128
- const universal = universalCause(causeLog, found);
2290
+ // The ONE predicate behind this decision and `repeatingFailureNote`'s wording, so the two
2291
+ // cannot come to different verdicts.
2292
+ const stopCause = earlyReturnCause(found, universalCause(causeLog, found));
2129
2293
  // How this run was started, so the note can hand over the re-run to send once it ends
2130
2294
  // instead of one for now — the platform refuses a second run against the same app and
2131
2295
  // database while this one walks (xr-molecule-flash-m89: the +19:59 note named a re-run
2132
2296
  // and its overlap hold refused that exact run 23 s later).
2133
2297
  const started = runByTask.get(taskId);
2134
- repeatingCauseNoted.add(taskId);
2135
- const earlyMs = Date.now() - startedAt;
2136
- waitedByTask.set(taskId, waitedBefore + earlyMs);
2137
- return {
2138
- taskId,
2139
- status: 'repeating_failure',
2140
- waitedMs: earlyMs,
2141
- note: repeatingFailureNote(found, {
2298
+ if (stopCause) {
2299
+ // …and only now is the pid walk paid for.
2300
+ repeatingCauseNoted.add(taskId);
2301
+ const earlyMs = Date.now() - startedAt;
2302
+ waitedByTask.set(taskId, waitedBefore + earlyMs);
2303
+ return {
2304
+ taskId,
2305
+ status: 'repeating_failure',
2306
+ waitedMs: earlyMs,
2307
+ note: repeatingFailureNote(found, {
2308
+ taskId,
2309
+ log,
2310
+ stop: await taskStopCommand(backend, taskId),
2311
+ universal: stopCause,
2312
+ ...(started ? { run: started } : {}),
2313
+ }),
2314
+ };
2315
+ }
2316
+ // A lead: this wait goes on, and the finding rides its result as a note — the executor has
2317
+ // to keep waiting for the run's own result, so it must not be told one in the status. The
2318
+ // look keeps running for the same reason: an app that goes down mid-run turns a shared
2319
+ // duration into a proven whole-run cause, and that one is worth returning early for.
2320
+ if (!midRunLeadNoted.has(taskId)) {
2321
+ midRunLeadNoted.add(taskId);
2322
+ midRunNote = repeatingFailureNote(found, {
2142
2323
  taskId,
2143
2324
  log,
2144
- stop: universal ? await taskStopCommand(backend, taskId) : '',
2145
- universal,
2325
+ stop: '',
2326
+ universal: null,
2146
2327
  ...(started ? { run: started } : {}),
2147
- }),
2148
- };
2328
+ });
2329
+ }
2149
2330
  }
2150
2331
  const waitedMs = Date.now() - startedAt;
2151
2332
  waitedByTask.set(taskId, waitedBefore + waitedMs);
@@ -2188,6 +2369,13 @@ export function buildTools(backend, config) {
2188
2369
  waitedMs: waitedBefore + waitedMs,
2189
2370
  }));
2190
2371
  const sliceField = soFar.trim() ? { outputSoFar: sanitizeOutput(soFar) } : {};
2372
+ // What this wait found mid-run, when it was not a reason to end early: it rides the result
2373
+ // the wait is about to hand back, because that result is where the executor decides what to
2374
+ // do next and a lead it never saw is a lead it cannot weigh. Not on the `stuck` answer
2375
+ // below — that verdict is about the command having stopped PRINTING and carries its own
2376
+ // instruction — and not on a finished run's result, where `failureCausesNote` reads the
2377
+ // whole log instead of the 40 KB tail a mid-run look is given.
2378
+ const midRun = midRunNote ? ` ${midRunNote}` : '';
2191
2379
  if (stillWriting && waitedBefore + waitedMs >= stuckAfterMs()) {
2192
2380
  waitedByTask.set(taskId, 0);
2193
2381
  return {
@@ -2196,7 +2384,7 @@ export function buildTools(backend, config) {
2196
2384
  waitedMs,
2197
2385
  ...sliceField,
2198
2386
  note: `${progress} Call wait_for_task again to keep waiting; peek with exec_command: ` +
2199
- `tail ${log}.`,
2387
+ `tail ${log}.${midRun}`,
2200
2388
  };
2201
2389
  }
2202
2390
  if (waitedBefore + waitedMs >= stuckAfterMs()) {
@@ -2223,9 +2411,11 @@ export function buildTools(backend, config) {
2223
2411
  (soFar.trim()
2224
2412
  ? `outputSoFar is the failure lines so far (up to ${RUNNING_FAILURE_LINES}) and then the end of ` +
2225
2413
  `the log, not the whole run — \`grep -n '✘' ${log} | head -40\` lists every failure, ` +
2226
- `\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error. `
2414
+ `\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error: this run writes ` +
2415
+ `each failure's own report into the log as that test fails, right under its ✘ line, ` +
2416
+ `not only in the summary at the end. `
2227
2417
  : `Nothing has been printed yet — \`grep -n '✘' ${log} | head -40\` lists failures as they appear. `) +
2228
- `Meanwhile do work that leaves the code under test alone (read the failures so far, plan the fixes): editing app/src or api/src now changes the code the run is testing, and its result no longer counts.`,
2418
+ `Meanwhile do work that leaves the code under test alone (read the failures so far, plan the fixes): editing app/src or api/src now changes the code the run is testing, and its result no longer counts.${midRun}`,
2229
2419
  };
2230
2420
  }
2231
2421
  let output;
@@ -2257,16 +2447,27 @@ export function buildTools(backend, config) {
2257
2447
  // flash-m83 (2026-10-16) spent three deploys (6.7 minutes) each fixing a single cause, and
2258
2448
  // the second and third were found by runs stopped before they walked the rest. Null for
2259
2449
  // anything that is not a run with failures in it.
2260
- const causes = failureCausesNote(output);
2450
+ // How this run was started, so the note can hand over the narrowed re-run of exactly its
2451
+ // failing tests instead of leaving the executor to re-run the whole suite (xr-molecule-
2452
+ // flash-m101 +12:13: 139 tests, ~4 minutes, to check two fixes).
2453
+ const run = runByTask.get(taskId);
2454
+ // The tests the request excludes are not causes to fix, and the re-run below inverts them
2455
+ // out — a result must never ask the executor to fix a test it also says does not count.
2456
+ // The log's own path rides along so a recap that leaves causes out can hand over the ONE
2457
+ // grep that prints their bodies: the stdout above is cut in the middle, so the log is where
2458
+ // they still are whole.
2459
+ const causes = failureCausesNote(output, excludedTestTitles?.() ?? [], run ? { ...run, log: ownFile ?? log } : undefined);
2261
2460
  // A backgrounded run is under no tool budget, and the exit file hands back the PIPELINE's
2262
2461
  // code — so a suite killed at a `timeout 280` the command carried behind a `| tail` reports
2263
2462
  // 0 here too. The run's start is in its own id ({@link taskStartedAt}), so the bound it used
2264
2463
  // is readable without new state: say stopped, and never hand the pipeline's 0 back as the
2265
2464
  // run's (X1 xr-molecule-flash-m93 re-sent that run three times).
2266
- const run = runByTask.get(taskId);
2267
2465
  const startedAtMs = taskStartedAt(taskId);
2268
2466
  const cut = run && startedAtMs !== null
2269
- ? commandCutRun(run.command, { exitCode, stdout: output, stderr: '' }, Date.now() - startedAtMs, 0)
2467
+ ? commandCutRun(run.command, { exitCode, stdout: output, stderr: '' }, Date.now() - startedAtMs, 0,
2468
+ // Same reason as the foreground path: the log is raw here, and the returned stdout
2469
+ // below is the truncated copy — detection must not read that copy.
2470
+ output)
2270
2471
  : null;
2271
2472
  if (cut) {
2272
2473
  return {
@@ -2281,12 +2482,19 @@ export function buildTools(backend, config) {
2281
2482
  };
2282
2483
  }
2283
2484
  // Same shaping as a foreground exec result, so the executor reads both
2284
- // the same way.
2485
+ // the same way. The exit file holds the PIPELINE's code: when a stage this rewrite could not
2486
+ // prove is output-only still follows the suite runner (`| tee run.log`), that code is the
2487
+ // filter's — report none and let the log's tally decide (m101 +25:54).
2488
+ const piped = run ? suiteRunPipedAfterRunner(run.command) : false;
2489
+ const reported = suiteRunExitCode(exitCode, output, piped);
2490
+ const noCodeNote = reported === null ? FILTERED_EXIT_NOTE : null;
2285
2491
  return {
2286
2492
  taskId,
2287
- exitCode,
2493
+ exitCode: reported,
2288
2494
  waitedMs,
2289
- ...(causes ? { note: causes } : {}),
2495
+ ...(causes || noCodeNote
2496
+ ? { note: [causes, noCodeNote].filter((n) => Boolean(n)).join(' ') }
2497
+ : {}),
2290
2498
  stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
2291
2499
  ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
2292
2500
  };