@molecule/api-ai-tools 1.8.2 → 1.8.3

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
@@ -10,6 +10,7 @@ import { commandCutRun, CUT_AT_BUDGET, cutRunSentence, suiteRunExitCode } from '
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';
13
+ import { leadingSleepPeek } from './sleep-peek.js';
13
14
  /**
14
15
  * Total waiting, per background task, after which wait_for_task reports it
15
16
  * stuck instead of waiting again. Env-tunable so a test can exercise it.
@@ -44,6 +45,36 @@ const runByTask = new Map();
44
45
  const FILTERED_EXIT_NOTE = 'No exit code is reported for this run: a stage after the test runner produced the pipeline’s ' +
45
46
  'code (or the code says 0 beside a failure in the tally), so read the pass/fail tally in the ' +
46
47
  'log instead.';
48
+ /**
49
+ * What a finished run says when its suite sits inside a `sh -c '<command>'` string: the stages are
50
+ * positions in THAT string, so the platform cannot point at one in the command's own text. Said
51
+ * instead of {@link FILTERED_EXIT_NOTE} — that note names a stage after the runner, and this run
52
+ * cannot be read as having one; a command with nothing piped after the runner keeps its code.
53
+ */
54
+ const UNPLACED_KEPT_NOTE = 'The suite sits inside a `sh -c` string, so its stages cannot be placed in the command’s own ' +
55
+ 'text; nothing follows the runner there, so the exit code above is the run’s own — read the ' +
56
+ 'pass/fail tally in the log for the verdict.';
57
+ /** {@link UNPLACED_KEPT_NOTE} when between them a stage after the runner produced the code. */
58
+ const UNPLACED_PIPED_NOTE = 'The suite sits inside a `sh -c` string, so its stages cannot be placed in the command’s own ' +
59
+ 'text, and a stage after the runner may have produced the pipeline’s code: none is reported, ' +
60
+ 'so read the pass/fail tally in the log instead.';
61
+ /**
62
+ * The sentence a finished suite run's result carries about its exit code, or null when the code it
63
+ * reports is the run's own and needs no explanation.
64
+ *
65
+ * {@link FILTERED_EXIT_NOTE} names a stage after the runner, so it is said only when the parse
66
+ * found one; a run whose stages cannot be placed says THAT instead, and keeps the command's own
67
+ * code when nothing follows the runner.
68
+ *
69
+ * @param placement - Where the code came from ({@link suiteRunPipelinePlacement}).
70
+ * @param reported - The code the result reports, or null when it reports none.
71
+ * @returns The note, or null.
72
+ */
73
+ function suiteExitCodeNote(placement, reported) {
74
+ if (placement.unplaced)
75
+ return reported === null ? UNPLACED_PIPED_NOTE : UNPLACED_KEPT_NOTE;
76
+ return reported === null ? FILTERED_EXIT_NOTE : null;
77
+ }
47
78
  /**
48
79
  * Task ids whose own-output file has already been looked for this process.
49
80
  * {@link outputFileByTask} is empty for every task after a restart — it is
@@ -229,8 +260,106 @@ function runningNote(clauses) {
229
260
  const joined = clauses.join('; ');
230
261
  return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
231
262
  }
263
+ /**
264
+ * The background task a leading `sleep` was about to peek at, and its state now — or null when the
265
+ * command has no task of this process's to report, in which case the sleep runs as written (the
266
+ * tool's own budget bounds it, as before).
267
+ *
268
+ * X1 xr-molecule-flash-m105 (+08:49 → +10:53, 2026-10-17) hand-polled with `sleep N; tail …`
269
+ * three times. The state the executor wanted was always one the platform already held: two tasks
270
+ * had ended 0.1 s in (exit 127, and `Error: No tests found`) and the third was live at 72 of 139
271
+ * with failures the log already carried. So a command that opens with a long sleep is answered
272
+ * from the same reads `wait_for_task` makes — the exit file, and the failure-lines-plus-tail slice
273
+ * of the log — and the sleep is dropped.
274
+ *
275
+ * The command names WHAT it is peeking at (`/tmp/<id>.log`, or the file the task redirects its own
276
+ * output to), and that task is the answer whether it has ended or not: the two dead peeks are
277
+ * exactly this shape. With no name to follow, the newest LIVE task is the answer — the point of
278
+ * the rule is that a sleep never outlives a run it could have been waiting on — and with no live
279
+ * task there is nothing to report.
280
+ *
281
+ * @param backend - The execution backend the task runs on.
282
+ * @param command - The command whose leading sleep is being replaced.
283
+ * @param seconds - The seconds that leading sleep asked for.
284
+ * @param ceilingMs - The wait ceiling a `wait_for_task` would have, so the ETA it states is the
285
+ * one an actual wait would act on.
286
+ * @returns One sentence naming the task's state and how to get its result, plus the log slice the
287
+ * caller attaches as `outputSoFar`; null when no task answers this command.
288
+ */
289
+ async function backgroundTaskState(backend, command, seconds, ceilingMs) {
290
+ const ids = [...runByTask.keys()];
291
+ if (!ids.length)
292
+ return null;
293
+ const exitCodeOf = async (id) => {
294
+ try {
295
+ const raw = await backend.readFile(`/tmp/${id}.exit`);
296
+ const code = Number.parseInt(raw.trim(), 10);
297
+ return Number.isFinite(code) ? code : 0;
298
+ }
299
+ catch (_error) {
300
+ // No exit file yet: the task is still running. Not an error.
301
+ return null;
302
+ }
303
+ };
304
+ const names = (id) => {
305
+ if (command.includes(`/tmp/${id}.log`))
306
+ return true;
307
+ const own = outputFileByTask.get(id);
308
+ return own !== undefined && command.includes(own);
309
+ };
310
+ const newestFirst = [...ids].reverse();
311
+ const namedId = newestFirst.find(names);
312
+ let target = namedId
313
+ ? { id: namedId, exitCode: await exitCodeOf(namedId) }
314
+ : null;
315
+ if (!target) {
316
+ // Bounded: a long session can leave many finished tasks behind, and each look costs a read.
317
+ for (const id of newestFirst.slice(0, 8)) {
318
+ const exitCode = await exitCodeOf(id);
319
+ if (exitCode === null) {
320
+ target = { id, exitCode };
321
+ break;
322
+ }
323
+ }
324
+ }
325
+ if (!target)
326
+ return null;
327
+ let slice = '';
328
+ try {
329
+ const r = await backend.run(runningOutputSliceCommand(`/tmp/${target.id}.log`, outputFileByTask.get(target.id)), { timeout: 5_000 });
330
+ slice = r.stdout ?? '';
331
+ }
332
+ catch (_error) {
333
+ // No slice: the note still says the task's state and how to get its result.
334
+ }
335
+ const secondsText = `Did not sleep ${Math.round(seconds)} s: background task \`${target.id}\``;
336
+ if (target.exitCode === null) {
337
+ const facts = waitProgressClauses({
338
+ taskId: target.id,
339
+ slice,
340
+ stillWriting: false,
341
+ bytes: null,
342
+ ceilingMs,
343
+ waitedMs: 0,
344
+ });
345
+ const failures = slice.trim() ? '; the failures already in its log are in outputSoFar' : '';
346
+ return {
347
+ note: `${secondsText} is live (${facts.join('; ')})${failures} — ` +
348
+ `\`wait_for_task ${target.id}\` returns its result the moment it ends.`,
349
+ slice,
350
+ };
351
+ }
352
+ const counter = logProgress(slice);
353
+ const progress = counter ? `, at ${counter.done} of ${counter.total} in its log` : '';
354
+ const failures = slice.trim() ? '; the failures in its log are in outputSoFar' : '';
355
+ return {
356
+ note: `${secondsText} had already ended (exit code ${target.exitCode})${progress}${failures} — ` +
357
+ `\`wait_for_task ${target.id}\` returns its result now.`,
358
+ slice,
359
+ };
360
+ }
232
361
  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';
362
+ 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, suiteRunPipelinePlacement, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
234
363
  /**
235
364
  * How long exec_command waits for a command it backgrounded only because the
236
365
  * command asked for more than the tool's ceiling, before handing back a task id.
@@ -298,8 +427,51 @@ export function moleculeDistReadNote(path) {
298
427
  : ' For one signature, read the matching .d.ts file.'));
299
428
  }
300
429
  /**
301
- * The refusal for a write whose target is inside an installed package, or null
302
- * when the path is not under a `node_modules` directory.
430
+ * The `node_modules` entry a path goes through, and the package name after it
431
+ * (`null` when the path IS the entry).
432
+ *
433
+ * @param path - An absolute path.
434
+ * @returns The entry and the name, or null when no `node_modules` component is in the path.
435
+ */
436
+ function nodeModulesParts(path) {
437
+ // A path may sit in a NESTED node_modules (`app/node_modules/...`) as well as the
438
+ // hoisted root one: both can be installed trees.
439
+ const at = /(?:^|\/)node_modules(?:\/|$)/.exec(path);
440
+ if (!at)
441
+ return null;
442
+ const head = path.slice(0, at.index);
443
+ const entry = `${head}${head === '' || head.endsWith('/') ? '' : '/'}node_modules`;
444
+ const named = /node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/|$)/.exec(path.slice(at.index));
445
+ return { entry, name: named?.[1] ?? null };
446
+ }
447
+ /**
448
+ * Build an {@link InstalledPackageProbe} from a shell runner (a backend's
449
+ * `run`, or a sandbox's `exec`): one command reports whether the entry is a
450
+ * link and whether a `package.json` resolves under it.
451
+ *
452
+ * @param run - Runs a command where the path lives.
453
+ * @returns The probe.
454
+ */
455
+ export function shellInstalledPackageProbe(run) {
456
+ return async (path) => {
457
+ const parts = nodeModulesParts(path);
458
+ if (!parts)
459
+ return { symlink: false, installed: false };
460
+ const { entry, name } = parts;
461
+ // A bare `@scope` is not a package, but removing it takes every package under
462
+ // it: treat the scope directory like the entry itself and look beneath it.
463
+ const scopeOnly = name !== null && /^@[^/]+$/.test(name);
464
+ const install = name !== null && !scopeOnly
465
+ ? `if [ -e ${shellQuote(`${entry}/${name}/package.json`)} ]; then echo installed; fi`
466
+ : `if find ${shellQuote(name === null ? entry : `${entry}/${name}`)} -maxdepth 3 -name package.json -print -quit 2>/dev/null | grep -q .; then echo installed; fi`;
467
+ const out = String((await run(`if [ -L ${shellQuote(entry)} ]; then echo symlink; fi; ${install}`))?.stdout ??
468
+ '');
469
+ return { symlink: out.includes('symlink'), installed: out.includes('installed') };
470
+ };
471
+ }
472
+ /**
473
+ * The refusal for a write whose target is inside an INSTALLED package, or null
474
+ * when the path is not under `node_modules` or nothing is installed there.
303
475
  *
304
476
  * `node_modules` is not the project's code: nothing written there survives the
305
477
  * next install, no deploy carries it, and a sandbox whose dependency tree is
@@ -312,6 +484,19 @@ export function moleculeDistReadNote(path) {
312
484
  * putting the file back. Reading these files stays allowed (that is how the
313
485
  * declarations above are read at all).
314
486
  *
487
+ * What is protected is the INSTALL, not the spelling: the decision comes from
488
+ * {@link InstalledPackageFacts}, never from the path alone (m106 above). Three
489
+ * things are the run's own to remove or replace —
490
+ *
491
+ * - a `node_modules` that is a SYMLINK: `rm`/`unlink` removes the link, never the
492
+ * tree it points at, so a link the run made cannot clobber anything;
493
+ * - anything under the run's own `.mol-scratch/` directory;
494
+ * - a `node_modules` with no `package.json` under it (nothing was ever installed).
495
+ *
496
+ * A path INSIDE the link is still decided by what resolves there: a link into a
497
+ * real tree is refused, because removing or writing through it reaches the
498
+ * install.
499
+ *
315
500
  * The restore route is named because the model that has ALREADY clobbered a file
316
501
  * (this refusal is too late for it) is the one who needs it, and because the
317
502
  * obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
@@ -319,19 +504,34 @@ export function moleculeDistReadNote(path) {
319
504
  * m91 used, and what both guards leave alone.
320
505
  *
321
506
  * @param path - An absolute path a tool is about to write.
507
+ * @param probe - Looks at the filesystem where the path lives.
322
508
  * @returns The one-line refusal naming the package, or null when nothing is installed there.
323
509
  */
324
- export function installedPackageWriteRefusal(path) {
325
- // A path may sit in a NESTED node_modules (`app/node_modules/...`) as well as the
326
- // hoisted root one: both are installed packages.
327
- const at = /(?:^|\/)node_modules(?:\/|$)/.exec(path);
328
- if (!at)
510
+ export async function installedPackageWriteRefusal(path, probe) {
511
+ const parts = nodeModulesParts(path);
512
+ if (!parts)
329
513
  return null;
330
- const named = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/|$)/.exec(path);
331
- const name = named?.[1] ?? null;
332
- const scoped = at[0].startsWith('/') ? path.slice(0, at.index) : '';
333
- const dir = name ? `${scoped}/node_modules/${name}` : null;
334
- const what = name ? `the installed package ${name}` : 'an installed package';
514
+ const { entry, name } = parts;
515
+ // The run's own scratch directory is the run's to clean: the platform tells it
516
+ // to put throwaway work there (system prompt, "Throwaway probes go in
517
+ // `.mol-scratch/`"), so nothing in it is the pre-installed tree.
518
+ if (/(?:^|\/)\.mol-scratch(?:\/|$)/.test(path))
519
+ return null;
520
+ // No probe, or a probe that could not answer: keep the old, conservative
521
+ // reading (everything under node_modules is an install).
522
+ const facts = probe ? await probe(path).catch(() => null) : null;
523
+ const symlink = facts?.symlink ?? false;
524
+ const installed = facts?.installed ?? true;
525
+ // The entry itself — the link (or a tree with nothing in it) the run may take
526
+ // away. Removing a symlink deletes the LINK only; its target is untouched.
527
+ if (name === null && (symlink || !installed))
528
+ return null;
529
+ // A path under the entry: `installed` resolved through any link, so it is true
530
+ // exactly when a write here would land in a real installation.
531
+ if (name !== null && !installed)
532
+ return null;
533
+ const dir = name ? `${entry}/${name}` : null;
534
+ const what = name ? `the installed package ${name}` : 'an installed node_modules tree';
335
535
  const change = 'Installed packages are read-only: the next install replaces that file and a deploy never ' +
336
536
  'carries it. Change its behaviour from your app instead (its options, your own handler, a ' +
337
537
  'bond), and where the package itself is wrong, report it rather than patching it here. ' +
@@ -488,9 +688,11 @@ export function buildTools(backend, config) {
488
688
  * @param path - Relative or absolute path requested by the tool input.
489
689
  * @returns The normalized path, when writing it is allowed.
490
690
  */
491
- function resolveWrite(path) {
691
+ async function resolveWrite(path) {
492
692
  const resolved = resolve(path);
493
- const refusal = installedPackageWriteRefusal(resolved);
693
+ // The guard decides from what is installed where the path lives, so the
694
+ // backend is asked (one command, only for a path that names node_modules).
695
+ const refusal = await installedPackageWriteRefusal(resolved, shellInstalledPackageProbe((command) => backend.run(command, { timeout: 8000 })));
494
696
  if (refusal)
495
697
  throw new ToolInputError(refusal);
496
698
  return resolved;
@@ -1046,7 +1248,7 @@ export function buildTools(backend, config) {
1046
1248
  const argErr = pathArgError(input.path, 'write_file');
1047
1249
  if (argErr)
1048
1250
  return { error: argErr };
1049
- const path = resolveWrite(input.path);
1251
+ const path = await resolveWrite(input.path);
1050
1252
  const content = input.content;
1051
1253
  if (content.length > MAX_WRITE_SIZE)
1052
1254
  return {
@@ -1095,7 +1297,7 @@ export function buildTools(backend, config) {
1095
1297
  const argErr = pathArgError(input.path, 'edit_file');
1096
1298
  if (argErr)
1097
1299
  return { error: argErr };
1098
- const path = resolveWrite(input.path);
1300
+ const path = await resolveWrite(input.path);
1099
1301
  // Support both formats: { replacements: [...] } (batch) and { old_string, new_string } (single, backwards-compatible)
1100
1302
  let replacements = input.replacements;
1101
1303
  // Accept the single format when BOTH are strings — `typeof` (not truthy) so
@@ -1729,7 +1931,7 @@ export function buildTools(backend, config) {
1729
1931
  }
1730
1932
  },
1731
1933
  async create_directory(input) {
1732
- const path = resolveWrite(input.path);
1934
+ const path = await resolveWrite(input.path);
1733
1935
  const parentPath = path.substring(0, path.lastIndexOf('/')) || root;
1734
1936
  const symlinkErr = await checkSymlink(parentPath);
1735
1937
  if (symlinkErr)
@@ -1747,8 +1949,8 @@ export function buildTools(backend, config) {
1747
1949
  async rename_file(input) {
1748
1950
  // Both ends: moving a package's file OUT is as much a change to the installed
1749
1951
  // package as moving one in.
1750
- const oldPath = resolveWrite(input.old_path);
1751
- const newPath = resolveWrite(input.new_path);
1952
+ const oldPath = await resolveWrite(input.old_path);
1953
+ const newPath = await resolveWrite(input.new_path);
1752
1954
  const oldSymlinkErr = await checkSymlink(oldPath);
1753
1955
  if (oldSymlinkErr)
1754
1956
  return { error: oldSymlinkErr };
@@ -1770,7 +1972,7 @@ export function buildTools(backend, config) {
1770
1972
  }
1771
1973
  },
1772
1974
  async delete_file(input) {
1773
- const path = resolveWrite(input.path);
1975
+ const path = await resolveWrite(input.path);
1774
1976
  const symlinkErr = await checkSymlink(path);
1775
1977
  if (symlinkErr)
1776
1978
  return { error: symlinkErr };
@@ -1820,7 +2022,28 @@ export function buildTools(backend, config) {
1820
2022
  cwd,
1821
2023
  readFile: (path) => backend.readFile(path),
1822
2024
  });
1823
- const command = traced.command;
2025
+ let command = traced.command;
2026
+ // A leading `sleep N; <read of a background task's log>` is a hand-rolled poll, and the
2027
+ // platform knows the task's state better than the log's tail does (see
2028
+ // {@link backgroundTaskState}): X1 xr-molecule-flash-m105 slept 225 s this way, two of the
2029
+ // three on tasks that had already ended. The sleep is dropped and the rest of the command
2030
+ // runs at once; with no background task behind it, the sleep is left exactly as sent — the
2031
+ // tool's own budget is the existing bound.
2032
+ const sleepPeek = leadingSleepPeek(command);
2033
+ const peekedTask = sleepPeek
2034
+ ? await backgroundTaskState(backend, command, sleepPeek.seconds, Math.max(5_000, (commandBudgetMs ?? 290_000) - 10_000))
2035
+ : null;
2036
+ const sleepNote = peekedTask?.note ?? null;
2037
+ if (sleepPeek && peekedTask) {
2038
+ command = sleepPeek.rest;
2039
+ if (!command) {
2040
+ return {
2041
+ status: 'task_state',
2042
+ note: peekedTask.note,
2043
+ ...(peekedTask.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2044
+ };
2045
+ }
2046
+ }
1824
2047
  // Detached: start it, hand back a handle, let the turn continue. A
1825
2048
  // command that outlives the tool's ceiling is otherwise simply lost —
1826
2049
  // measured across six agent runs, ten of them were killed at the
@@ -1937,18 +2160,22 @@ export function buildTools(backend, config) {
1937
2160
  ...(output.length > MAX_OUTPUT_SIZE
1938
2161
  ? { truncated: true, fullOutput: log }
1939
2162
  : {}),
1940
- error: `${cutRunSentence(cut)} ` +
2163
+ error: (sleepNote ? `${sleepNote} ` : '') +
2164
+ `${cutRunSentence(cut)} ` +
1941
2165
  (isSuiteRunCommand(command) ? SUITE_OVER_BUDGET_ADVICE : SPLIT_WORK_ADVICE),
1942
2166
  };
1943
2167
  }
1944
2168
  // The exit file holds the PIPELINE's code: when a stage this rewrite could not
1945
2169
  // 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);
2170
+ // is the filter's — report none and let the log's tally decide (m101 +25:54). The
2171
+ // note says this only when the parse found such a stage (m105 +08:12).
2172
+ const placement = suiteRunPipelinePlacement(streaming.command);
2173
+ const reported = suiteRunExitCode(code, output, placement.piped);
2174
+ const exitNote = suiteExitCodeNote(placement, reported);
1949
2175
  const notes = [
2176
+ ...(sleepNote ? [sleepNote] : []),
1950
2177
  ...streaming.changes,
1951
- ...(reported === null ? [FILTERED_EXIT_NOTE] : []),
2178
+ ...(exitNote === null ? [] : [exitNote]),
1952
2179
  ];
1953
2180
  return {
1954
2181
  stdout,
@@ -1965,22 +2192,25 @@ export function buildTools(backend, config) {
1965
2192
  taskId: id,
1966
2193
  log,
1967
2194
  exitFile,
1968
- note: (overCeiling
1969
- ? declaredSeconds > ceilingSeconds
1970
- ? `This command asks for ${declaredSeconds}s, more than this tool's ${ceilingSeconds}s, and its ` +
1971
- 'output only appears when it ends — so it was started in the background instead. '
1972
- : `A Playwright run can take longer than this tool's ${ceilingSeconds}s, and behind a pipe its ` +
1973
- 'output only appears when it ends — so it was started in the background instead. '
1974
- : '') +
2195
+ ...(peekedTask?.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2196
+ note: (sleepNote ? `${sleepNote} ` : '') +
2197
+ (overCeiling
2198
+ ? declaredSeconds > ceilingSeconds
2199
+ ? `This command asks for ${declaredSeconds}s, more than this tool's ${ceilingSeconds}s, and its ` +
2200
+ 'output only appears when it ends — so it was started in the background instead. '
2201
+ : `A Playwright run can take longer than this tool's ${ceilingSeconds}s, and behind a pipe its ` +
2202
+ 'output only appears when it ends — so it was started in the background instead. '
2203
+ : '') +
1975
2204
  `Started in the background. Its output is written to ${log} and comes back as the ` +
1976
- `stdout of wait_for_task when the command finishes (read_file cannot reach /tmp — ` +
1977
- `to peek mid-run, exec_command: tail ${log}). If you have other work, do it and ` +
1978
- `check back. If you need this result before you can continue, call wait_for_task ` +
1979
- `with this taskId — never sleep and read the log by hand.` +
2205
+ `stdout of wait_for_task when the command finishes (read_file cannot reach /tmp). If ` +
2206
+ `you have other work, do it and check back. If you need this result before you can ` +
2207
+ `continue, call wait_for_task with this taskId: it returns the moment the command ` +
2208
+ `ends, returns early when a failure cause repeats, and reports progress and the ` +
2209
+ `failures so far — never sleep and read the log by hand.` +
1980
2210
  (streaming.changes.length ? ` ${streaming.changes.join(' ')}` : '') +
1981
2211
  (prioritized.note ? ` ${prioritized.note}` : '') +
1982
2212
  (ownFile
1983
- ? ` This command writes its own output to ${ownFile}, so the task log stays empty: peek with exec_command: tail ${ownFile}.`
2213
+ ? ` This command writes its own output to ${ownFile}, so the task log stays empty: wait_for_task reads that file when the command ends and reports its progress while it runs — never sleep and tail it by hand.`
1984
2214
  : ''),
1985
2215
  };
1986
2216
  }
@@ -2063,7 +2293,8 @@ export function buildTools(backend, config) {
2063
2293
  // The facts, so a reader that can also see the suite itself (its own `.hurl` file
2064
2294
  // count) says the same sentence with the total the output alone cannot give.
2065
2295
  cutRun: cut,
2066
- error: `${cutLead} ` +
2296
+ error: (sleepNote ? `${sleepNote} ` : '') +
2297
+ `${cutLead} ` +
2067
2298
  // The tool's own budget stopping a run that printed nothing: keep the sentence that
2068
2299
  // says why the output is empty, since nothing else in the result can say it.
2069
2300
  (cut.stoppedBy === 'tool-budget'
@@ -2078,8 +2309,11 @@ export function buildTools(backend, config) {
2078
2309
  stdout,
2079
2310
  stderr,
2080
2311
  exitCode: result.exitCode,
2081
- ...(ceilingNote || traced.change
2082
- ? { note: [ceilingNote, traced.change].filter(Boolean).join(' ') }
2312
+ ...(peekedTask?.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2313
+ ...(sleepNote || ceilingNote || traced.change
2314
+ ? {
2315
+ note: [sleepNote, ceilingNote, traced.change].filter(Boolean).join(' '),
2316
+ }
2083
2317
  : {}),
2084
2318
  };
2085
2319
  }
@@ -2383,8 +2617,8 @@ export function buildTools(backend, config) {
2383
2617
  status: 'running',
2384
2618
  waitedMs,
2385
2619
  ...sliceField,
2386
- note: `${progress} Call wait_for_task again to keep waiting; peek with exec_command: ` +
2387
- `tail ${log}.${midRun}`,
2620
+ note: `${progress} Call wait_for_task again to keep waiting — it returns the moment the ` +
2621
+ `command ends and reports the failures as they appear.${midRun}`,
2388
2622
  };
2389
2623
  }
2390
2624
  if (waitedBefore + waitedMs >= stuckAfterMs()) {
@@ -2484,10 +2718,13 @@ export function buildTools(backend, config) {
2484
2718
  // Same shaping as a foreground exec result, so the executor reads both
2485
2719
  // the same way. The exit file holds the PIPELINE's code: when a stage this rewrite could not
2486
2720
  // 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;
2721
+ // filter's — report none and let the log's tally decide (m101 +25:54). The note says this
2722
+ // only when the parse found such a stage (m105 +08:12).
2723
+ const placement = run
2724
+ ? suiteRunPipelinePlacement(run.command)
2725
+ : { piped: false, unplaced: false };
2726
+ const reported = suiteRunExitCode(exitCode, output, placement.piped);
2727
+ const noCodeNote = suiteExitCodeNote(placement, reported);
2491
2728
  return {
2492
2729
  taskId,
2493
2730
  exitCode: reported,
@@ -1 +1 @@
1
- {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAMH,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAA;IACZ,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,0FAA0F;IAC1F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAA;IACZ,6FAA6F;IAC7F,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAErC,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,uDAAuD;AACvD,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,iDAAiD;AACjD,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,gEAAgE;AAChE,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB,QAAO,MA+LvC,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAyBnE;AAiBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4B/F;AAaD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,8BAA8B,CAAA;AAuG/D,oGAAoG;AACpG,MAAM,WAAW,qBAAqB;IACpC,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE;IAChD,OAAO,EAAE,MAAM,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5C,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAyBjC"}
1
+ {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAMH,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAA;IACZ,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,0FAA0F;IAC1F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAA;IACZ,6FAA6F;IAC7F,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAErC,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,uDAAuD;AACvD,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,iDAAiD;AACjD,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,gEAAgE;AAChE,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB,QAAO,MA+LvC,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAyBnE;AAiBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4B/F;AAaD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,8BAA8B,CAAA;AA2G/D,oGAAoG;AACpG,MAAM,WAAW,qBAAqB;IACpC,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE;IAChD,OAAO,EAAE,MAAM,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5C,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAyBjC"}
@@ -26,7 +26,7 @@
26
26
  *
27
27
  * @module
28
28
  */
29
- import { commandSegments } from './command-position.js';
29
+ import { locatedCommandSegments } from './command-position.js';
30
30
  import { isDiagnosisRun } from './suite-overlap.js';
31
31
  import { isPlaywrightRunCommand } from './utilities.js';
32
32
  /** How many traces one read reports, newest first. */
@@ -380,31 +380,36 @@ function namesTraceMode(command) {
380
380
  * shell parse can place.
381
381
  *
382
382
  * The flag has to land INSIDE the Playwright step, not at the end of the whole command: a run
383
- * Flash sends almost every time ends `… 2>&1 | tail -60` or `… > log 2>&1; echo "exit=$?";
384
- * grep -c "" log`, and a flag appended after those is an operand of `tail`/`grep` — GNU `tail`
385
- * refuses it outright (`tail: option used in invalid context -- 6`) and Playwright never sees it
383
+ * Flash sends almost every time ends `… 2>&1 | tail -60` or `… > log 2>&1; echo "exit=$?"; grep -c
384
+ * "" log`, and a flag appended after those is an operand of `tail`/`grep` — GNU `tail` refuses it
385
+ * outright (`tail: option used in invalid context -- 6`) and Playwright never sees it
386
386
  * (xr-molecule-flash-m102 +07:40 → +07:53).
387
387
  *
388
- * The step is found by the package's own shell parse ({@link commandSegments}), the one every
389
- * runner classifier reads: it answers which SEGMENT runs `playwright test` in command position,
390
- * so a `playwright test` named in an argument (`grep -n "playwright test" e2e/README.md`) is not
391
- * a run and is left alone. Each segment's text is its program and arguments re-joined with single
392
- * spaces, so its offset in the command is found by locating that text, walking the segments in
393
- * order. A segment whose text cannot be located verbatim (irregular whitespace) ends the walk —
394
- * null, and the caller adds nothing rather than splicing into a guess.
388
+ * The step is found by the package's own shell parse ({@link locatedCommandSegments}), the one
389
+ * every runner classifier reads: it answers which STATEMENT runs `playwright test` in command
390
+ * position, and where that statement's program word sits in the command. The offset is the parse's
391
+ * own token position, never a search for the re-joined text — that text drops grouping braces and
392
+ * single-space-joins the words, so a command carrying a heredoc body or a `"quoted"` program found
393
+ * nothing at all and the flag was not added.
395
394
  *
396
395
  * @param command - The shell command.
397
396
  * @returns The offset just after `test`, or null.
398
397
  */
399
398
  function playwrightTestWordEnd(command) {
400
- let cursor = 0;
401
- for (const segment of commandSegments(command)) {
402
- const at = command.indexOf(segment.text, cursor);
403
- if (at < 0)
404
- return null;
405
- cursor = at + segment.text.length;
406
- if (/^playwright\s+test\b/.test(segment.text))
407
- return at + 'playwright test'.length;
399
+ for (const located of locatedCommandSegments(command)) {
400
+ const segment = located.segment;
401
+ if (!segment || located.programOffset === null)
402
+ continue;
403
+ if (!/^playwright\s+test\b/.test(segment.text))
404
+ continue;
405
+ // `playwright` and `test` are the statement's own first two words; the flag follows `test`.
406
+ const words = new RegExp(/\S+/.source, 'g');
407
+ words.lastIndex = located.programOffset;
408
+ const program = words.exec(command);
409
+ if (!program)
410
+ continue;
411
+ const second = words.exec(command);
412
+ return second ? second.index + second[0].length : null;
408
413
  }
409
414
  return null;
410
415
  }
@@ -222,18 +222,44 @@ export interface SuiteFilterStrip {
222
222
  * @returns The rewritten command and the stages dropped.
223
223
  */
224
224
  export declare function stripTrailingSuiteFilters(command: string): SuiteFilterStrip;
225
+ /** Whether a finished run's exit code belongs to a stage after the suite runner. */
226
+ export interface SuiteRunPipelinePlacement {
227
+ /**
228
+ * True when a pipe this tool must not drop still follows the suite runner: the code in the
229
+ * result is that stage's, not the run's.
230
+ */
231
+ piped: boolean;
232
+ /**
233
+ * True when the suite (or a stage of its pipeline) sits inside a nested `sh -c '<command>'`
234
+ * string, so the parse cannot place it in the command's own text. The caller then says so
235
+ * plainly instead of implying a stage it can point at — and keeps the command's own code when
236
+ * no pipe follows the runner, because on that command the pipeline's last stage IS the run.
237
+ */
238
+ unplaced: boolean;
239
+ }
240
+ /**
241
+ * Where the suite runner's exit code comes from: the runner itself, or a stage piped after it.
242
+ *
243
+ * {@link suiteRunPipedAfterRunner}'s one question, with the honest third answer kept apart. The
244
+ * code a finished run reports is its LAST pipeline stage's, so a stage after the runner makes the
245
+ * code the stage's — but only when such a stage EXISTS. The platform used to report "piped" for
246
+ * any run whose statements it could not place in the text, which one heredoc body was enough to
247
+ * cause: on X1 xr-molecule-flash-m105 (+08:12) a config written in the same `exec_command` made
248
+ * the run come back with no exit code at all, and the note under it claimed a stage after the
249
+ * runner that was not there. The parse now places every statement (`locatedCommandSegments`), so
250
+ * "unplaced" is the rare, named case rather than a guess dressed as an answer.
251
+ *
252
+ * @param command - The shell command, after the streaming rewrite when one was applied.
253
+ * @returns Whether a stage follows the runner, and whether the run could be placed at all.
254
+ */
255
+ export declare function suiteRunPipelinePlacement(command: string): SuiteRunPipelinePlacement;
225
256
  /**
226
257
  * Whether the command's last step runs a suite whose output still passes through a stage after
227
258
  * the runner — one {@link stripTrailingSuiteFilters} could not prove is output-only (`| tee log`,
228
259
  * `| xargs …`). The pipeline's exit status is that stage's, not the run's.
229
260
  *
230
- * A command whose stages cannot be placed in the text (irregular whitespace inside the run) is
231
- * reported as piped too: whether a pipe follows the runner is then UNKNOWN, and an unknown exit
232
- * code that gets trusted is a failing run read as a pass — the failure this rule exists to stop.
233
- *
234
261
  * @param command - The shell command, after the streaming rewrite when one was applied.
235
- * @returns True when a pipe this tool must not drop still follows the suite runner, or when the
236
- * command runs a suite the parse cannot place.
262
+ * @returns True when a pipe this tool must not drop still follows the suite runner.
237
263
  */
238
264
  export declare function suiteRunPipedAfterRunner(command: string): boolean;
239
265
  /**
@@ -916,9 +942,12 @@ export declare function failuresByCause(log: string, excluded?: readonly string[
916
942
  * stopped early happened to reach.
917
943
  *
918
944
  * A test the request excludes is not one of the causes — it is dropped and named once, at the
919
- * end, so a result never asks to fix a test it also says to ignore. Where the caller knows how
920
- * the run was started, the note also hands over the narrowed re-run of exactly those tests
921
- * ({@link rerunOfRunningSuite}) instead of leaving the executor to re-run the whole suite.
945
+ * end, so a result never asks to fix a test it also says to ignore. A test the runner itself
946
+ * declared `flaky` (it failed once and passed on its config retry) is not a cause either: it is
947
+ * named apart from them, once, as what the tally called it ({@link flakyTestsDeclared}). Where
948
+ * the caller knows how the run was started, the note also hands over the narrowed re-run of
949
+ * exactly those tests ({@link rerunOfRunningSuite}) instead of leaving the executor to re-run the
950
+ * whole suite.
922
951
  *
923
952
  * @param log - The finished run's output.
924
953
  * @param excluded - The test titles the request carves out, if the caller knows them.