@molecule/api-ai-tools 1.9.16 → 1.9.19

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.
@@ -31,7 +31,7 @@
31
31
  import { posix } from 'node:path';
32
32
  import { commandSegments, locatedCommandSegments } from './command-position.js';
33
33
  import { commandRunsNoTests } from './no-test-run.js';
34
- import { shellQuote, suiteRunStatements } from './utilities.js';
34
+ import { matchingExcludedTitles, shellQuote, suiteRunStatements } from './utilities.js';
35
35
  /**
36
36
  * A command with its heredoc bodies removed: the runs, kills and hosts it names in a file it
37
37
  * WRITES are not what the command itself does.
@@ -108,17 +108,48 @@ export function namesSuiteConfig(command) {
108
108
  export function withSuiteConfig(command, config) {
109
109
  if (!config)
110
110
  return command;
111
- const at = command.search(/\bplaywright\s+test\b/);
112
- if (at === -1)
111
+ const run = playwrightStatement(command);
112
+ if (run === null)
113
113
  return command;
114
- const head = command.slice(0, at);
115
- const rest = command.slice(at);
116
- const end = rest.search(/&&|\|\||;|\|/);
117
- const statement = end === -1 ? rest : rest.slice(0, end);
118
- const tail = end === -1 ? '' : rest.slice(end);
119
- if (namesSuiteConfig(statement))
114
+ if (namesSuiteConfig(command.slice(run.start, run.end)))
120
115
  return command;
121
- return `${head}${statement.replace(/^(playwright\s+test)\b/, `$1 --config ${shellQuote(config)}`)}${tail}`;
116
+ // Right after the runner's own words (`playwright test <here>`), so a launcher in front of
117
+ // them — `npx --no-install`, `node <path>/playwright` — is untouched and the flag is the
118
+ // runner's own argument wherever it sits.
119
+ return `${command.slice(0, run.afterRunner)} --config ${shellQuote(config)}${command.slice(run.afterRunner)}`;
120
+ }
121
+ /**
122
+ * A redirect that names a FILE for a stream whose target a re-run would destroy: `> f`, `>> f`,
123
+ * `1> f`, `&> f`. `2>` is stderr and deliberately not matched; a descriptor duplication
124
+ * (`2>&1`, `>&2`) has no file target and does not match either, because `&` cannot start one.
125
+ */
126
+ const FILE_REDIRECT = /(^|\s)(&>>?|1?>>?)(\s*)(?:'([^']+)'|"([^"]+)"|([^\s;&|]+))/g;
127
+ /** A target that is not a log anyone could lose: rewriting it would CREATE a file. */
128
+ const DEVICE_TARGET = /^\/dev\//;
129
+ /**
130
+ * The command with every file redirect pointed at a fresh file beside its original target
131
+ * (`/tmp/pw-full.log` → `/tmp/pw-full.rerun.log`), and nothing else changed.
132
+ *
133
+ * A re-run composed from a run's own command keeps the run's redirect by construction, and the
134
+ * run wrote the walk's only report to that file: xr-molecule-flash-m173 handed over *"Send this
135
+ * once the run ends: … npx playwright test …/articles.spec.ts:32 --retries=0 … > /tmp/pw-full.log
136
+ * 2>&1"* — the ONE narrowed re-run that was about to check the fixes would have overwritten the
137
+ * full walk's log, destroying the other 11 failures' error reports. Following the platform's own
138
+ * advice must never destroy the evidence it was based on.
139
+ *
140
+ * @param command - A shell command.
141
+ * @returns The command whose file redirects write to a fresh target.
142
+ */
143
+ function redirectToFreshFile(command) {
144
+ return command.replace(FILE_REDIRECT, (whole, before, op, space, single, double, bare) => {
145
+ const target = single ?? double ?? bare ?? '';
146
+ // `/dev/null` and friends are not a report to preserve; leaving them keeps the re-run silent
147
+ // the way the run was, where rewriting them would create a file nobody asked for.
148
+ if (!target || DEVICE_TARGET.test(target))
149
+ return whole;
150
+ const quote = single !== undefined ? "'" : double !== undefined ? '"' : '';
151
+ return `${before}${op}${space}${quote}${target}.rerun.log${quote}`;
152
+ });
122
153
  }
123
154
  /**
124
155
  * The whole-suite command made scoped to the tests it should re-run instead: the same
@@ -126,6 +157,10 @@ export function withSuiteConfig(command, config) {
126
157
  * suite, `--last-failed` dropped (it re-runs the tests that never ran too, X1 flash-m60) and
127
158
  * `--retries=0` set (a re-run of a known failure does not need to pay for retries).
128
159
  *
160
+ * The re-run keeps the run's `> <file>` redirect only in the sense of keeping its OUTPUT there:
161
+ * the target is rewritten to a fresh sibling ({@link redirectToFreshFile}), so a re-run never
162
+ * overwrites the log the run itself is the evidence for (xr-molecule-flash-m173).
163
+ *
129
164
  * `config` is the config the run was measured under ({@link withSuiteConfig}): the narrowed re-run
130
165
  * carries it whenever the command itself does not, so a run that dropped `--config` cannot be
131
166
  * handed back a command with the same defect (xr-molecule-flash-m113: every failing test was
@@ -147,16 +182,17 @@ export function withSuiteConfig(command, config) {
147
182
  * @returns The scoped command, or the command unchanged when it runs no Playwright suite.
148
183
  */
149
184
  export function scopedRerunCommand(command, locations, excluded = [], config, cwd) {
150
- const at = command.search(/\bplaywright\s+test\b/);
151
- if (at === -1)
185
+ const run = playwrightStatement(command);
186
+ if (run === null)
152
187
  return command;
153
- const head = command.slice(0, at);
154
- const rest = command.slice(at);
155
188
  // Only the first statement runs the suite; the rest (`| tail -50`, `&& echo done`)
156
189
  // is left exactly as it was.
157
- const end = rest.search(/&&|\|\||;|\|/);
158
- const statement = end === -1 ? rest : rest.slice(0, end);
159
- const tail = end === -1 ? '' : rest.slice(end);
190
+ const statement = command.slice(run.start, run.end);
191
+ const tail = command.slice(run.end);
192
+ // The arguments after the runner's own words (`playwright test`), where the narrowed re-run
193
+ // writes its locations — the launcher in front of them (`npx --no-install`, `node
194
+ // <path>/playwright`) is not part of the arguments.
195
+ const args = command.slice(run.afterRunner, run.end);
160
196
  // One literal alternation, regex-escaped and shell-quoted: a title carries parentheses and
161
197
  // quotes (`should only allow comment author to delete`) that are regex and shell syntax alike.
162
198
  const invert = excluded.length > 0 && !/--grep-invert\b/.test(command)
@@ -176,16 +212,18 @@ export function scopedRerunCommand(command, locations, excluded = [], config, cw
176
212
  // nothing — "No tests found" — so every location is rendered in a form Playwright can match
177
213
  // ({@link playwrightLocations}).
178
214
  const narrowed = playwrightLocations(locations, command, cwd);
179
- const scoped = dropSupersededSpecFiles(statement, narrowed)
215
+ const dropped = dropSupersededSpecFiles(args, narrowed)
180
216
  .replace(/\s*--last-failed\b/g, '')
181
- .replace(/\s*--retries(?:[= ]\d+)?/g, '')
182
- .replace(/^(playwright\s+test)\b/, `$1 ${narrowed.join(' ')} --retries=0${carry}${invert}`);
217
+ .replace(/\s*--retries(?:[= ]\d+)?/g, '');
218
+ const scoped = command.slice(0, run.afterRunner) +
219
+ ` ${narrowed.join(' ')} --retries=0${carry}${invert}` +
220
+ dropped;
183
221
  // A command that starts the suite AGAIN after this statement is a chain of walks, not a re-run:
184
222
  // everything from the next suite runner on is dropped, so the composed command carries ONE walk.
185
223
  // X1 xr-molecule-flash-m157 (+14:02) was handed a re-run that still carried the chain's second
186
224
  // whole walk — 4.5 minutes measuring code it had not changed.
187
225
  const rerunTail = suiteRunStatements(tail).length > 0 ? '' : tail;
188
- return `${head}${scoped}${rerunTail}`;
226
+ return `${redirectToFreshFile(scoped)}${redirectToFreshFile(rerunTail)}`;
189
227
  }
190
228
  /** A directory a `cd` can name literally: no `~`, no variable, no glob. */
191
229
  const LITERAL_DIR = /^[A-Za-z0-9_./+-]+$/;
@@ -267,7 +305,8 @@ function playwrightLocations(locations, command, cwd) {
267
305
  /** A spec-file argument Playwright accepts as its positional filter — never a flag. */
268
306
  const SPEC_FILE_ARGUMENT = /^(?!-)[^\s]*\.(?:spec|test)\.[cm]?[jt]sx?(?::\d+(?::\d+)?)?$/;
269
307
  /**
270
- * The statement with every bare spec-file argument taken out once the re-run names failure locations.
308
+ * The runner's own arguments with every bare spec-file argument taken out once the re-run names
309
+ * failure locations.
271
310
  *
272
311
  * The re-run is "just these": Playwright matches each positional argument against every collected
273
312
  * file's path, so a bare file left beside the `file:line`s re-admits ALL of that file's tests.
@@ -276,23 +315,20 @@ const SPEC_FILE_ARGUMENT = /^(?!-)[^\s]*\.(?:spec|test)\.[cm]?[jt]sx?(?::\d+(?::
276
315
  * failures sat in two of them — 32 tests, ~3 min, instead of the 8 failures' ~30 s. A file with no
277
316
  * failure in it has nothing to re-run, so it goes too.
278
317
  *
279
- * @param statement - The Playwright statement, starting at its own `playwright test`.
318
+ * The launcher's words are not arguments and are not here: the caller passes what follows
319
+ * `playwright test` ({@link playwrightStatement}'s `afterRunner`), so `node <path>/playwright
320
+ * test` and `npx playwright test` lose the same spec files.
321
+ *
322
+ * @param args - The run's own arguments, after `playwright test`.
280
323
  * @param locations - The failing `file:line`s the re-run names.
281
- * @returns The statement without its bare spec-file arguments.
324
+ * @returns The arguments without their bare spec-file arguments.
282
325
  */
283
- function dropSupersededSpecFiles(statement, locations) {
326
+ function dropSupersededSpecFiles(args, locations) {
284
327
  if (locations.length === 0)
285
- return statement;
286
- const runner = /^(playwright\s+test)\b/.exec(statement);
287
- if (runner === null)
288
- return statement;
289
- const head = runner[1];
290
- const kept = statement
291
- .slice(head.length)
292
- .replace(/(^|\s+)([^\s]+)(?=\s|$)/g, (whole, _space, token) => {
328
+ return args;
329
+ return args.replace(/(^|\s+)([^\s]+)(?=\s|$)/g, (whole, _space, token) => {
293
330
  return SPEC_FILE_ARGUMENT.test(token) ? '' : whole;
294
331
  });
295
- return `${head}${kept}`;
296
332
  }
297
333
  /**
298
334
  * The run in flight that makes a second run a hazard, or null when nothing is in the way.
@@ -320,11 +356,78 @@ export function suiteRunInTheWay(candidate, inFlight) {
320
356
  * closing quote is that echo. A version-pinned `playwright@1.44.0 test` is the same run.
321
357
  */
322
358
  const PLAYWRIGHT_RUN = /^(?:@playwright\/test|playwright)(?:@\S+)?\s+test(?=\s|$)/;
359
+ /**
360
+ * The statement of a command that runs `playwright test`, and where its own arguments begin.
361
+ *
362
+ * Both rewrites that compose a command ({@link withSuiteConfig}, {@link scopedRerunCommand}) read
363
+ * the run through the shared shell parse — the same answer every classifier gets — instead of
364
+ * searching the text for `playwright test`. The search missed the runner reached by file (`node
365
+ * <path>/@playwright/test/cli.js test`, xr-molecule-flash-m179), so a narrowed re-run of that run
366
+ * silently stayed the whole suite.
367
+ *
368
+ * `afterRunner` is the offset just past the runner's own words (`playwright test`): the
369
+ * sub-command is the word after the program word, wherever the program word is (the script path
370
+ * behind `node`, the name itself otherwise), so an option written there is the run's own.
371
+ *
372
+ * @param command - The shell command.
373
+ * @returns The statement's span and its argument offset, or null when no statement runs the suite.
374
+ */
375
+ function playwrightStatement(command) {
376
+ for (const located of locatedCommandSegments(command)) {
377
+ const segment = located.segment;
378
+ if (!segment || !PLAYWRIGHT_RUN.test(segment.text))
379
+ continue;
380
+ // A statement inside a nested `sh -c '<command>'` has no offsets here: its positions are
381
+ // positions in that string, so nothing may be spliced at them.
382
+ if (located.start === null || located.end === null || located.programOffset === null)
383
+ return null;
384
+ const words = [...command.slice(located.programOffset).matchAll(/\S+/g)];
385
+ const subcommand = words[1];
386
+ if (subcommand === undefined)
387
+ return null;
388
+ return {
389
+ start: located.start,
390
+ end: located.end,
391
+ afterRunner: located.programOffset + (subcommand.index ?? 0) + subcommand[0].length,
392
+ };
393
+ }
394
+ return null;
395
+ }
323
396
  /**
324
397
  * What narrows a Playwright run to a part of the suite: a named file or line, a title filter, or
325
398
  * the tests the run before it printed (`--last-failed`, `--only-changed`).
326
399
  */
327
400
  const NARROWED_RUN = /\.(?:spec|test)\.[cm]?[jt]sx?\b|\.[cm]?[jt]sx?:\d+|(?:^|\s)(?:-g|--grep(?:-invert)?|--last-failed|--only-changed)\b/;
401
+ /**
402
+ * A `--grep-invert` (or its `-G` alias) with the pattern Playwright takes: quoted or bare, `=` or
403
+ * whitespace between flag and value, the last occurrence winning as it does on the runner's own
404
+ * command line. The flag's own name has to be followed by `=` or whitespace, so `--grep` never
405
+ * matches `--grep-invert`; and a value never crosses a shell separator, so a flag written last
406
+ * cannot eat the statement after it.
407
+ */
408
+ const GREP_INVERT_VALUE = /(?:^|[\s;&|(])(?:--grep-invert|-G)(?:=|\s+)(?:'([^']*)'|"([^"]*)"|([^\s;&|]+))/g;
409
+ /**
410
+ * The `--grep-invert` pattern a Playwright invocation carries, or null when it carries none.
411
+ *
412
+ * @param run - The Playwright invocation's command-position text.
413
+ * @returns The pattern as the command wrote it, or null.
414
+ */
415
+ function grepInvertPattern(run) {
416
+ const last = [...run.matchAll(GREP_INVERT_VALUE)].at(-1);
417
+ if (!last)
418
+ return null;
419
+ return last[1] ?? last[2] ?? last[3] ?? null;
420
+ }
421
+ /**
422
+ * The invocation without its `--grep-invert` flags, so the OTHER narrowers can be read on their
423
+ * own: a run that greps AND names a file is still a diagnosis whatever its inversion excludes.
424
+ *
425
+ * @param run - The Playwright invocation's command-position text.
426
+ * @returns The same text with every `--grep-invert`/`-G` flag and value taken out.
427
+ */
428
+ function withoutGrepInvert(run) {
429
+ return run.replace(GREP_INVERT_VALUE, ' ');
430
+ }
328
431
  /**
329
432
  * The Playwright invocation's text when the command starts one, or null.
330
433
  *
@@ -383,10 +486,19 @@ function boundsFailures(command) {
383
486
  * diagnosis. A command that starts no Playwright run is not one either (false): the question is
384
487
  * about a run already read as a Playwright suite.
385
488
  *
489
+ * A `--grep-invert` is only a narrowing when it excludes MORE than the request already excludes.
490
+ * When the caller passes those approved titles ({@link DiagnosisRunContext}) and the inverted
491
+ * pattern names one of them — the request's own "except …" scope written as the flag, which
492
+ * molecule-dev's `invertDecision` reads the same way — the run still walks the whole proof scope,
493
+ * and it is not a diagnosis. xr-molecule-flash-m170 (+33:16): the deployed proof carried exactly
494
+ * that exclusion, the rewriter added `--retries=0` to it, and the run lost the suite's retries on
495
+ * the one walk where a network blip is likeliest.
496
+ *
386
497
  * @param command - The command as the executor sent it.
498
+ * @param context - The request's approved exclusions, when the caller knows them.
387
499
  * @returns True when the run is a diagnosis or a re-run, not the whole suite.
388
500
  */
389
- export function isDiagnosisRun(command) {
501
+ export function isDiagnosisRun(command, context) {
390
502
  if (boundsFailures(command))
391
503
  return true;
392
504
  const run = playwrightRun(command);
@@ -396,5 +508,11 @@ export function isDiagnosisRun(command) {
396
508
  // the suite either, and is not the whole-suite run the retries belong on.
397
509
  if (commandRunsNoTests(run))
398
510
  return true;
399
- return NARROWED_RUN.test(run);
511
+ const inverted = grepInvertPattern(run);
512
+ if (inverted === null)
513
+ return NARROWED_RUN.test(run);
514
+ // A file, a line, a `-g`, `--last-failed` … narrows the run whatever the inversion also drops.
515
+ if (NARROWED_RUN.test(withoutGrepInvert(run)))
516
+ return true;
517
+ return matchingExcludedTitles(inverted, context?.approvedExclusions ?? []).length === 0;
400
518
  }
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAkJ9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAiSD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAsEnE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,QAEA,CAAA;AAE9B;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAI6C,CAAA;AAmBlF;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8FAA8F;IAC9F,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;AAoBpF;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,CAAC,GACxE,qBAAqB,CAkBvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,qBAAqB,GAC5B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiCxB;AA2GD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAqlFxF"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAsK9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAiSD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAqFnE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,QAEA,CAAA;AAE9B;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAI6C,CAAA;AAmBlF;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8FAA8F;IAC9F,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;AAoBpF;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,CAAC,GACxE,qBAAqB,CAkBvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,qBAAqB,GAC5B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiCxB;AA2GD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAurFxF"}
package/dist/tools.js CHANGED
@@ -13,6 +13,7 @@ import { guardToolExecute, missingParamError, ToolInputError } from './input-nor
13
13
  import { diffLineCount } from './line-diff.js';
14
14
  import { pipelineExitCodeNote } from './pipeline-exit-note.js';
15
15
  import { TOOL_SCHEMAS } from './schemas.js';
16
+ import { foregroundServerCommand, SERVER_OVER_BUDGET_ADVICE, serverCommandRefusal, startsServerProgram, } from './server-command.js';
16
17
  import { leadingSleepPeek } from './sleep-peek.js';
17
18
  /**
18
19
  * Total waiting, per background task, after which wait_for_task reports it
@@ -30,6 +31,19 @@ const waitedByTask = new Map();
30
31
  const logBytesByTask = new Map();
31
32
  /** The file a background command writes its own output to, when it redirects it. */
32
33
  const outputFileByTask = new Map();
34
+ /**
35
+ * Task ids whose launch put the suite on {@link PLAYWRIGHT_FAILURES_INLINE_ENV} — the inline
36
+ * failure switch, announced by the launch's own change list and written as an `export` at the
37
+ * head of the command.
38
+ *
39
+ * The wait note may promise that each failure's own error sits under its ✘ line only when the run
40
+ * was actually given the switch: a run whose shape withheld the rewrite prints its failures only
41
+ * in the end-of-run summary, and the note that promised inline reports sent the model grepping a
42
+ * file they were not in (the m99 class, recurring at xr-molecule-flash-m173 +17:07, +39:14).
43
+ * Process memory, like {@link outputFileByTask}: a task this process did not launch recovers it
44
+ * from the script on disk, which carries the rewritten command.
45
+ */
46
+ const inlineFailuresByTask = new Set();
33
47
  /**
34
48
  * How each background task was started: the command as it was actually run (after
35
49
  * {@link streamingBackgroundCommand} rewrote it) and the directory it ran in.
@@ -421,12 +435,23 @@ async function backgroundTaskState(backend, command, seconds, ceilingMs) {
421
435
  }
422
436
  import { suiteCommandTarget } from './suite-overlap.js';
423
437
  import { diagnosisTraceCommand } from './trace-artifacts.js';
424
- 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, TOOL_RESULT_CHARS, truncateMiddle, universalCause, whitespaceTolerantReplace, withoutSuiteTimeout, } from './utilities.js';
438
+ 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, PLAYWRIGHT_FAILURES_INLINE_ENV, printsOnlySource, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatedSuiteRunError, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, searchTimeoutError, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunBudgetCommand, suiteRunPipelinePlacement, suiteRunPriorityLaunch, TOOL_RESULT_CHARS, truncateMiddle, universalCause, whitespaceTolerantReplace, withoutSuiteTimeout, } from './utilities.js';
425
439
  /**
426
440
  * How long exec_command waits for a command it backgrounded only because the
427
441
  * command asked for more than the tool's ceiling, before handing back a task id.
428
442
  */
429
443
  const OVER_CEILING_GRACE_MS = 45_000;
444
+ /**
445
+ * The machine-readable mark on a result whose run carried the supplied suite's own settings
446
+ * ({@link ToolBuildConfig.suiteBudget}) — the flags actually written, so a consumer's notes can
447
+ * treat those settings as equal to the suite's instead of re-reading the config.
448
+ *
449
+ * @param pinned - The flags put on the command.
450
+ * @returns The result field, or nothing when no flag was placed.
451
+ */
452
+ function suiteBudgetField(pinned) {
453
+ return pinned.length === 0 ? {} : { suiteBudget: { pinned: [...pinned] } };
454
+ }
430
455
  /**
431
456
  * The closing advice on an over-budget message for a command that is not a test
432
457
  * suite: the work it was chaining is what to split.
@@ -2258,11 +2283,38 @@ export function buildTools(backend, config) {
2258
2283
  if (blocked)
2259
2284
  return { error: blocked };
2260
2285
  }
2261
- // A diagnosis run of a suite whose config records no trace gets one recorded anyway
2286
+ // A command that starts the suite more than once is refused BEFORE anything runs, in the
2287
+ // foreground as well as in the background: each walk measures the same code, and only the
2288
+ // last one is rewritten here. The background launcher already refused it; a foreground
2289
+ // command ran both, and the platform's note read the first walk's tally beside the second
2290
+ // walk's host (X1 xr-molecule-flash-m170 +06:02). ONE reading — {@link repeatedSuiteRunError}
2291
+ // — so every launch site refuses the same commands.
2292
+ const repeatedSuiteRun = repeatedSuiteRunError(rawCommand);
2293
+ if (repeatedSuiteRun)
2294
+ return { error: repeatedSuiteRun };
2295
+ // A FOREGROUND command that starts a server never returns: it holds this tool for its whole
2296
+ // budget and — behind `| tail`, the way these probes are usually written — prints nothing
2297
+ // until it does. X1 xr-molecule-flash-m167 (+19:16 → +24:04) sent
2298
+ // `npx tsx --env-file=.env /tmp/probe.mts | tail -15` where the script imports the app's
2299
+ // `api/src/server.ts` and calls `create(0)`, whose `listen()` keeps the process alive; the
2300
+ // run waited 290 s and was then told to "run a smaller unit". Refused BEFORE anything runs,
2301
+ // with the two shapes that work: a background launch (which the platform tracks and hands a
2302
+ // task id for) or a probe of the API already on :4000.
2303
+ if (input.run_in_background !== true) {
2304
+ const server = await foregroundServerCommand(rawCommand, (path) => backend.readFile(path));
2305
+ if (server) {
2306
+ return {
2307
+ error: serverCommandRefusal(server, Math.max(1, Math.round((commandBudgetMs ?? 290_000) / 1000))),
2308
+ };
2309
+ }
2310
+ }
2311
+ // A Playwright run of a suite whose config records no trace gets one recorded anyway
2262
2312
  // (`diagnosisTraceCommand`, trace-artifacts.ts): the failing tests' own network log is what
2263
2313
  // tells a swallowed request error from a selector, and X1 xr-molecule-flash-m100 spent the
2264
2314
  // last minutes of its run hand-writing Chromium probes for what the trace it never wrote
2265
- // down would have said. The whole rule is that function's; the proof run is never touched.
2315
+ // down would have said. The whole rule is that function's; the whole-suite proof run gets it
2316
+ // too, since a trace is retained only for a failing test (m163's proof run left none and the
2317
+ // executor hunted one that did not exist).
2266
2318
  const traced = await diagnosisTraceCommand({
2267
2319
  command: rawCommand,
2268
2320
  cwd,
@@ -2290,6 +2342,32 @@ export function buildTools(backend, config) {
2290
2342
  };
2291
2343
  }
2292
2344
  }
2345
+ // The supplied suite's own budget. A run whose runner config sets a different per-test
2346
+ // timeout or worker count measures a run the grader never sees: xr-molecule-flash-m169 wrote
2347
+ // a 60 s wrapper config, the platform proved the suite on it, and the official 15 s grader
2348
+ // then failed five tests the proof could never show. The suite's values are put back on the
2349
+ // runner's own arguments — the one place Playwright lets them win over every config — and the
2350
+ // result carries the sentence. The consumer reads the configs (it knows where the suite and
2351
+ // its base live); this tool only writes the flags where they belong. Before the background
2352
+ // branch, so a task launch and a foreground run carry them alike.
2353
+ let budgetNote = null;
2354
+ let budgetPinned = [];
2355
+ if (config?.suiteBudget !== undefined && isPlaywrightRunCommand(command)) {
2356
+ try {
2357
+ const pin = await config.suiteBudget(command, cwd);
2358
+ if (pin !== null && pin !== undefined) {
2359
+ budgetNote = pin.note;
2360
+ if (pin.flags.length > 0) {
2361
+ const pinned = suiteRunBudgetCommand(command, pin.flags);
2362
+ command = pinned.command;
2363
+ budgetPinned = pinned.placed;
2364
+ }
2365
+ }
2366
+ }
2367
+ catch (_error) {
2368
+ // A consumer's scan that fails changes nothing: the command runs exactly as asked.
2369
+ }
2370
+ }
2293
2371
  // Detached: start it, hand back a handle, let the turn continue. A
2294
2372
  // command that outlives the tool's ceiling is otherwise simply lost —
2295
2373
  // measured across six agent runs, ten of them were killed at the
@@ -2346,7 +2424,12 @@ export function buildTools(backend, config) {
2346
2424
  command = lifted.command;
2347
2425
  // Keep the log growing while it runs (see streamingBackgroundCommand):
2348
2426
  // an empty log is what wait_for_task reads as stuck.
2349
- const streaming = streamingBackgroundCommand(command);
2427
+ // The request's own exclusions ride along: a `--grep-invert` that names one of them is
2428
+ // the proof scope written as a flag, not a diagnosis, so it keeps the suite's retries
2429
+ // (xr-molecule-flash-m170 +33:16). The getter is the host's, read here at launch time.
2430
+ const streaming = streamingBackgroundCommand(command, {
2431
+ approvedExclusions: excludedTestTitles?.() ?? [],
2432
+ });
2350
2433
  // A command that starts the suite more than once is refused here, before anything is
2351
2434
  // written or launched: each walk measures the same code, and only the last is rewritten
2352
2435
  // (X1 xr-molecule-flash-m157 +14:02 — two whole walks chained with `;`).
@@ -2375,6 +2458,10 @@ export function buildTools(backend, config) {
2375
2458
  const ownFile = ownTarget === null ? null : posix.resolve(cwd, ownTarget);
2376
2459
  if (ownFile)
2377
2460
  outputFileByTask.set(id, ownFile);
2461
+ // Whether the run's failures print as they fail, so a wait can promise that only when
2462
+ // it is true (see inlineFailuresByTask).
2463
+ if (streaming.command.startsWith(`export ${PLAYWRIGHT_FAILURES_INLINE_ENV}=1; `))
2464
+ inlineFailuresByTask.add(id);
2378
2465
  runByTask.set(id, { command: streaming.command, cwd });
2379
2466
  await backend.writeFile(script, `${streaming.command}\n`);
2380
2467
  // The clock the grace return below reports a stopped run against: the command is
@@ -2428,6 +2515,7 @@ export function buildTools(backend, config) {
2428
2515
  ? { truncated: true, fullOutput: log }
2429
2516
  : {}),
2430
2517
  error: (sleepNote ? `${sleepNote} ` : '') +
2518
+ (budgetNote ? `${budgetNote} ` : '') +
2431
2519
  cutRunSentence(cut) +
2432
2520
  // A suite stopped in the background is never sent to the background again:
2433
2521
  // the sentence says what to do, and the advice's reason (it “takes the run
@@ -2454,6 +2542,7 @@ export function buildTools(backend, config) {
2454
2542
  : pipelineExitCodeNote(streaming.command, reported, output);
2455
2543
  const notes = [
2456
2544
  ...(sleepNote ? [sleepNote] : []),
2545
+ ...(budgetNote ? [budgetNote] : []),
2457
2546
  ...streaming.changes,
2458
2547
  ...(exitNote === null ? [] : [exitNote]),
2459
2548
  ];
@@ -2464,6 +2553,7 @@ export function buildTools(backend, config) {
2464
2553
  ...(pipeNote === null ? {} : { exitCodeNote: pipeNote }),
2465
2554
  ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
2466
2555
  ...(notes.length ? { note: notes.join(' ') } : {}),
2556
+ ...suiteBudgetField(budgetPinned),
2467
2557
  };
2468
2558
  }
2469
2559
  await new Promise((r) => setTimeout(r, 1_500));
@@ -2474,7 +2564,9 @@ export function buildTools(backend, config) {
2474
2564
  log,
2475
2565
  exitFile,
2476
2566
  ...(peekedTask?.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2567
+ ...suiteBudgetField(budgetPinned),
2477
2568
  note: (sleepNote ? `${sleepNote} ` : '') +
2569
+ (budgetNote ? `${budgetNote} ` : '') +
2478
2570
  (overCeiling
2479
2571
  ? declaredSeconds > ceilingSeconds
2480
2572
  ? `This command asks for ${declaredSeconds}s, more than this tool's ${ceilingSeconds}s, and its ` +
@@ -2575,6 +2667,7 @@ export function buildTools(backend, config) {
2575
2667
  // count) says the same sentence with the total the output alone cannot give.
2576
2668
  cutRun: cut,
2577
2669
  error: (sleepNote ? `${sleepNote} ` : '') +
2670
+ (budgetNote ? `${budgetNote} ` : '') +
2578
2671
  `${cutLead} ` +
2579
2672
  // The tool's own budget stopping a run that printed nothing: keep the sentence that
2580
2673
  // says why the output is empty, since nothing else in the result can say it.
@@ -2583,7 +2676,11 @@ export function buildTools(backend, config) {
2583
2676
  ? ' — and a pipe through tail/head holds everything back until the command ends, so it printed nothing: run it without the pipe (use a line reporter and let the output stream)'
2584
2677
  : ''}. `
2585
2678
  : '') +
2586
- (isSuiteRunCommand(command) ? SUITE_OVER_BUDGET_ADVICE : SPLIT_WORK_ADVICE),
2679
+ (startsServerProgram(command)
2680
+ ? SERVER_OVER_BUDGET_ADVICE
2681
+ : isSuiteRunCommand(command)
2682
+ ? SUITE_OVER_BUDGET_ADVICE
2683
+ : SPLIT_WORK_ADVICE),
2587
2684
  };
2588
2685
  }
2589
2686
  // A pipeline reports its LAST stage's code: `tsc | head` says 0 beside a printed type
@@ -2597,9 +2694,10 @@ export function buildTools(backend, config) {
2597
2694
  exitCode: result.exitCode,
2598
2695
  ...(exitCodeNote === null ? {} : { exitCodeNote }),
2599
2696
  ...(peekedTask?.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2600
- ...(sleepNote || ceilingNote || traced.change
2697
+ ...suiteBudgetField(budgetPinned),
2698
+ ...(sleepNote || ceilingNote || traced.change || budgetNote
2601
2699
  ? {
2602
- note: [sleepNote, ceilingNote, traced.change].filter(Boolean).join(' '),
2700
+ note: [sleepNote, ceilingNote, traced.change, budgetNote].filter(Boolean).join(' '),
2603
2701
  }
2604
2702
  : {}),
2605
2703
  };
@@ -2679,13 +2777,19 @@ export function buildTools(backend, config) {
2679
2777
  /^mol-bg-[a-z0-9]+-[a-z0-9]+$/.test(taskId)) {
2680
2778
  outputFileProbed.add(taskId);
2681
2779
  try {
2682
- const target = outputRedirectTarget(await backend.readFile(`/tmp/${taskId}.sh`));
2780
+ const script = await backend.readFile(`/tmp/${taskId}.sh`);
2781
+ const target = outputRedirectTarget(script);
2683
2782
  // A relative target was written against the command's own cwd, which
2684
2783
  // the handle does not carry; the project root is where a background
2685
2784
  // command runs unless the caller passed one. An absolute target is
2686
2785
  // already resolved and stays as written.
2687
2786
  if (target)
2688
2787
  outputFileByTask.set(taskId, posix.resolve(root, target));
2788
+ // The rewritten command is on disk with the `export` head it was given: a task this
2789
+ // process did not launch still says whether its failures print as they fail
2790
+ // (see inlineFailuresByTask).
2791
+ if (script.startsWith(`export ${PLAYWRIGHT_FAILURES_INLINE_ENV}=1; `))
2792
+ inlineFailuresByTask.add(taskId);
2689
2793
  }
2690
2794
  catch (_error) {
2691
2795
  // No script under that id: this process did not start the task, so
@@ -2743,6 +2847,13 @@ export function buildTools(backend, config) {
2743
2847
  };
2744
2848
  }
2745
2849
  const log = `/tmp/${taskId}.log`;
2850
+ // The file every note below must point the executor at: the one its own reads and
2851
+ // `outputSoFar` come from. A command that redirects its output (`> /tmp/pw-full.log`) leaves
2852
+ // the task log holding a word, so a grep composed against THAT prints nothing — xr-molecule-
2853
+ // flash-m173 (+17:07) obeyed the +12:18 note's `grep … /tmp/mol-bg-….log`, every grep
2854
+ // printed empty, and the whole mid-run diagnosis window was lost. The task's own wrapper log
2855
+ // stays `log` wherever something is read from both (the size probe and the slice do).
2856
+ const readLog = outputFileByTask.get(taskId) ?? log;
2746
2857
  const exitFile = `/tmp/${taskId}.exit`;
2747
2858
  // Leave the outer tool ceiling a margin so this returns a result rather
2748
2859
  // than being killed by it.
@@ -2842,7 +2953,7 @@ export function buildTools(backend, config) {
2842
2953
  waitedMs: earlyMs,
2843
2954
  note: repeatingFailureNote(noted, {
2844
2955
  taskId,
2845
- log,
2956
+ log: readLog,
2846
2957
  stop: await taskStopCommand(backend, taskId),
2847
2958
  universal: stopCause,
2848
2959
  ...(started ? { run: started } : {}),
@@ -2866,7 +2977,7 @@ export function buildTools(backend, config) {
2866
2977
  const editsInvalidateRun = started === undefined || suiteCommandTarget(started.command) === 'local';
2867
2978
  midRunNote = repeatingFailureNote(found, {
2868
2979
  taskId,
2869
- log,
2980
+ log: readLog,
2870
2981
  stop: '',
2871
2982
  universal: null,
2872
2983
  ...(started ? { run: started } : {}),
@@ -2928,7 +3039,7 @@ export function buildTools(backend, config) {
2928
3039
  const secondWalk = secondSuiteWalk(soFar) !== null;
2929
3040
  const stopInstead = secondWalk
2930
3041
  ? ` Stop it (\`exec_command: ${await taskStopCommand(backend, taskId)}\`) and read the ` +
2931
- `first walk's tally in ${log} — the second walk measures nothing new.`
3042
+ `first walk's tally in ${readLog} — the second walk measures nothing new.`
2932
3043
  : null;
2933
3044
  if (stillWriting && waitedBefore + waitedMs >= stuckAfterMs()) {
2934
3045
  waitedByTask.set(taskId, 0);
@@ -2950,7 +3061,7 @@ export function buildTools(backend, config) {
2950
3061
  status: 'stuck',
2951
3062
  waitedMs: waitedBefore + waitedMs,
2952
3063
  note: `${Math.round((waitedBefore + waitedMs) / 1000)}s of waiting and it has not exited — it is ` +
2953
- `stuck, not slow. See what it printed with exec_command (tail /tmp/${taskId}.log — ` +
3064
+ `stuck, not slow. See what it printed with exec_command (tail ${readLog} — ` +
2954
3065
  `read_file cannot reach /tmp), kill it (\`exec_command: ` +
2955
3066
  `${await taskStopCommand(backend, taskId)}\`) and move on; do not wait on it again.`,
2956
3067
  };
@@ -2969,11 +3080,19 @@ export function buildTools(backend, config) {
2969
3080
  `keep waiting — its result carries the log output. ` +
2970
3081
  (soFar.trim()
2971
3082
  ? `outputSoFar is the failure lines so far (up to ${RUNNING_FAILURE_LINES}) and then the end of ` +
2972
- `the log, not the whole run — \`grep -n '✘' ${log} | head -40\` lists every failure, ` +
2973
- `\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error: this run writes ` +
2974
- `each failure's own report into the log as that test fails, right under its ✘ line, ` +
2975
- `not only in the summary at the end. `
2976
- : `Nothing has been printed yet — \`grep -n '✘' ${log} | head -40\` lists failures as they appear. `) +
3083
+ `the log, not the whole run — \`grep -n '✘' ${readLog} | head -40\` lists every failure, ` +
3084
+ `\`grep -n -A 20 '<test name>' ${readLog}\` reads one failure's error: ` +
3085
+ // The promise is made only when the launch actually set the inline switch
3086
+ // (inlineFailuresByTask). A run whose shape withheld it prints each failure's
3087
+ // error in the end-of-run summary alone, and telling the model to grep under
3088
+ // the ✘ row sent it through greps that could not match (xr-molecule-flash-m173
3089
+ // +17:07, +39:14 — the m99 class, recurring).
3090
+ (inlineFailuresByTask.has(taskId)
3091
+ ? `this run writes each failure's own report into the log as that test fails, ` +
3092
+ `right under its ✘ line, not only in the summary at the end. `
3093
+ : `this run prints each failure's error only in the summary at the end; until ` +
3094
+ `then the ✘ rows are all there is. `)
3095
+ : `Nothing has been printed yet — \`grep -n '✘' ${readLog} | head -40\` lists failures as they appear. `) +
2977
3096
  // What an edit does to this run depends on what the run MEASURES: the sandbox's own
2978
3097
  // app is re-served from app/src as it is edited, so the run's result stops counting; a
2979
3098
  // run against the deployed build is untouched by a local edit, and the executor can