@molecule/api-ai-tools 1.9.1 → 1.9.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.
@@ -28,7 +28,8 @@
28
28
  *
29
29
  * @module
30
30
  */
31
- import { commandSegments } from './command-position.js';
31
+ import { posix } from 'node:path';
32
+ import { commandSegments, locatedCommandSegments } from './command-position.js';
32
33
  import { commandRunsNoTests } from './no-test-run.js';
33
34
  import { shellQuote } from './utilities.js';
34
35
  /**
@@ -131,18 +132,21 @@ export function withSuiteConfig(command, config) {
131
132
  * `Cannot navigate to invalid URL` because the re-run loaded no base URL).
132
133
  *
133
134
  * @param command - The whole-suite run's command.
134
- * @param locations - The failing `file:line`s to re-run, as the run's own log printed them — or,
135
- * for a run that started outside the suite, the absolute form Playwright matches each file
136
- * argument against.
135
+ * @param locations - The failing `file:line`s to re-run, as the run's own log printed them. A
136
+ * location the reporter printed relative to the runner's own directory (`../../fixtures/…`,
137
+ * which no absolute path contains) is resolved here ({@link playwrightLocations}), so the
138
+ * command this returns is one Playwright can match.
137
139
  * @param excluded - The test titles the request carves out. They are inverted out
138
140
  * (`--grep-invert`) so a re-run of the failing locations cannot report one of them again —
139
141
  * xr-molecule-flash-m101 re-ran the whole 139-test suite and spent ~4 minutes to be told about
140
142
  * a test the request had already said does not count.
141
143
  * @param config - The config the run loaded, when the caller knows it; added when the command
142
144
  * does not name one.
145
+ * @param cwd - The directory `exec_command` was given, when it was; the base a location is
146
+ * resolved against when the command does not `cd` anywhere.
143
147
  * @returns The scoped command, or the command unchanged when it runs no Playwright suite.
144
148
  */
145
- export function scopedRerunCommand(command, locations, excluded = [], config) {
149
+ export function scopedRerunCommand(command, locations, excluded = [], config, cwd) {
146
150
  const at = command.search(/\bplaywright\s+test\b/);
147
151
  if (at === -1)
148
152
  return command;
@@ -165,12 +169,155 @@ export function scopedRerunCommand(command, locations, excluded = [], config) {
165
169
  // The run's own config: the command's `--config` when it has one, else the config the caller
166
170
  // recorded for the run. A re-run without it measures nothing (m113).
167
171
  const carry = config && !namesSuiteConfig(statement) ? ` --config ${shellQuote(config)}` : '';
168
- const scoped = statement
172
+ // Playwright matches each positional argument as a regex against the ABSOLUTE path of every test
173
+ // file it collects (`playwright test --help`: "Each argument is treated as a regular expression.
174
+ // Matching is performed against the absolute file paths."), while the reporter prints a failure
175
+ // relative to the runner's own directory. A `../../fixtures/…` location therefore matches
176
+ // nothing — "No tests found" — so every location is rendered in a form Playwright can match
177
+ // ({@link playwrightLocations}).
178
+ const narrowed = playwrightLocations(locations, command, cwd);
179
+ const scoped = dropSupersededSpecFiles(statement, narrowed)
169
180
  .replace(/\s*--last-failed\b/g, '')
170
181
  .replace(/\s*--retries(?:[= ]\d+)?/g, '')
171
- .replace(/^(playwright\s+test)\b/, `$1 ${locations.join(' ')} --retries=0${carry}${invert}`);
182
+ .replace(/^(playwright\s+test)\b/, `$1 ${narrowed.join(' ')} --retries=0${carry}${invert}`);
172
183
  return `${head}${scoped}${tail}`;
173
184
  }
185
+ /** A directory a `cd` can name literally: no `~`, no variable, no glob. */
186
+ const LITERAL_DIR = /^[A-Za-z0-9_./+-]+$/;
187
+ /** A word without its shell quotes. */
188
+ const unquoted = (word) => word.replace(/^['"]|['"]$/g, '');
189
+ /**
190
+ * The absolute directory the Playwright statement in a command runs in, or null when the command
191
+ * does not say.
192
+ *
193
+ * The reporter prints a failing test's file relative to the process directory the runner started
194
+ * in (`../fixtures/…` from a run whose suite lives outside its own tree), so the directory has to
195
+ * come from the command itself: `exec_command`'s `cwd` when it was given, then each `cd` before
196
+ * the runner. Only a literal `cd` is followed — a target holding a variable, a glob or `~`
197
+ * resolves nowhere useful, and a guessed absolute path is worse than the relative one the
198
+ * reporter printed.
199
+ *
200
+ * @param command - The shell command.
201
+ * @param cwd - The `cwd` the tool was given, when it was.
202
+ * @returns The absolute directory, or null.
203
+ */
204
+ function suiteRunDirectory(command, cwd) {
205
+ let dir = typeof cwd === 'string' && cwd.startsWith('/') ? posix.normalize(cwd) : null;
206
+ for (const { segment } of locatedCommandSegments(command)) {
207
+ if (segment === null)
208
+ continue;
209
+ if (segment.program === 'cd') {
210
+ const target = unquoted(segment.args[0] ?? '').replace(/\/+$/, '');
211
+ if (!target || target.startsWith('-') || target.startsWith('~') || !LITERAL_DIR.test(target))
212
+ return null;
213
+ // A shell `cd /abs` DISCARDS the directory before it; only a relative target resolves
214
+ // against the one the run started in.
215
+ if (target.startsWith('/'))
216
+ dir = posix.normalize(target);
217
+ else if (dir === null)
218
+ return null;
219
+ else
220
+ dir = posix.normalize(posix.join(dir, target));
221
+ continue;
222
+ }
223
+ if (segment.program === 'playwright' && segment.args[0] === 'test')
224
+ break;
225
+ }
226
+ return dir;
227
+ }
228
+ /** A failure location's path and its `:line[:column]` suffix, split the way Playwright does. */
229
+ const LOCATION_LINE = /^(.*?)(:\d+(?::\d+)?)$/;
230
+ /**
231
+ * Each location in a form Playwright matches, so a re-run composed from a run's own report can
232
+ * actually run.
233
+ *
234
+ * Playwright compiles every positional file argument into a regex and tests it against the
235
+ * absolute path of each collected test file (`playwright test --help`; `createFiltersFromArguments`
236
+ * → `forceRegExp` → `createFileMatcher`, verified against Playwright 1.63 with `--list`: the
237
+ * reporter's `../../fixtures/…/articles.spec.ts:3` lists 0 tests, the absolute path lists 1). A
238
+ * location the reporter printed relative to the runner's directory is resolved against it; where
239
+ * that directory cannot be known, only the leading `./`/`../` segments are dropped, which leaves a
240
+ * suffix the absolute path still contains (`fixtures/…/articles.spec.ts:3` matches).
241
+ *
242
+ * A location without a leading dot is left alone: it is either absolute or already a suffix of the
243
+ * absolute path, and rewriting it against a mis-guessed base could only break it
244
+ * (xr-molecule-flash-m118 +16:34 "No tests found"; m124 +18:38 the platform's own "Re-run just
245
+ * these" command).
246
+ *
247
+ * @param locations - The failing `file:line`s as the run's log printed them.
248
+ * @param command - The run's command, for its own `cd`s.
249
+ * @param cwd - The `cwd` the tool was given, when it was.
250
+ * @returns The locations, matchable by Playwright.
251
+ */
252
+ function playwrightLocations(locations, command, cwd) {
253
+ const base = suiteRunDirectory(command, cwd);
254
+ return locations.map((location) => {
255
+ const [, file = '', suffix = ''] = LOCATION_LINE.exec(location) ?? [];
256
+ if (!file.startsWith('./') && !file.startsWith('../'))
257
+ return location;
258
+ const resolved = base === null ? file.replace(/^(?:\.{1,2}\/)+/, '') : posix.resolve(base, file);
259
+ return `${resolved}${suffix}`;
260
+ });
261
+ }
262
+ /** A spec-file argument Playwright accepts as its positional filter — never a flag. */
263
+ const SPEC_FILE_ARGUMENT = /^(?!-)[^\s]*\.(?:spec|test)\.[cm]?[jt]sx?(?::\d+(?::\d+)?)?$/;
264
+ /**
265
+ * A `file:line` location's own file part: Playwright prints failures as
266
+ * `url-navigation.spec.ts:13:7`, and the file is the argument that supersedes.
267
+ *
268
+ * @param location - A location (`file.spec.ts:13`, `file.spec.ts:13:7`) or a spec-file argument.
269
+ * @returns The path without its line numbers.
270
+ */
271
+ function locationFile(location) {
272
+ return location.replace(/:\d+(?::\d+)?$/, '');
273
+ }
274
+ /**
275
+ * Whether two spellings name the same spec file: equal, or one the absolute (or longer) form of
276
+ * the other. The command and the reporter routinely spell the same file differently — the model
277
+ * names `url-navigation.spec.ts`, the summary prints `specs/e2e/url-navigation.spec.ts:13`, and a
278
+ * hold hands back `/workspace/fixtures/realworld/specs/e2e/url-navigation.spec.ts:13`.
279
+ *
280
+ * @param argument - The spec-file argument as the command wrote it.
281
+ * @param location - The failing location.
282
+ * @returns True when the location is inside that file.
283
+ */
284
+ function sameSpecFile(argument, location) {
285
+ const a = locationFile(argument)
286
+ .replace(/^['"]|['"]$/g, '')
287
+ .replace(/^\.\//, '');
288
+ const l = locationFile(location)
289
+ .replace(/^['"]|['"]$/g, '')
290
+ .replace(/^\.\//, '');
291
+ return a === l || a.endsWith(`/${l}`) || l.endsWith(`/${a}`);
292
+ }
293
+ /**
294
+ * The statement with every spec-file argument its OWN failure locations already name taken out.
295
+ *
296
+ * A `file:line` inside a named file supersedes the whole file, so keeping both runs the file plus
297
+ * the locations — the whole suite again, with the failures duplicated inside it. xr-molecule-flash-m118
298
+ * (+45:24) composed just that: the model's `npx playwright test … url-navigation.spec.ts` was
299
+ * narrowed to the 14 `file:line`s AND the bare file, "~297s" for a file and fourteen duplicates.
300
+ *
301
+ * @param statement - The Playwright statement, starting at its own `playwright test`.
302
+ * @param locations - The failing `file:line`s the re-run names.
303
+ * @returns The statement without those superseded file arguments.
304
+ */
305
+ function dropSupersededSpecFiles(statement, locations) {
306
+ if (locations.length === 0)
307
+ return statement;
308
+ const runner = /^(playwright\s+test)\b/.exec(statement);
309
+ if (runner === null)
310
+ return statement;
311
+ const head = runner[1];
312
+ const kept = statement
313
+ .slice(head.length)
314
+ .replace(/(^|\s+)([^\s]+)(?=\s|$)/g, (whole, _space, token) => {
315
+ if (!SPEC_FILE_ARGUMENT.test(token))
316
+ return whole;
317
+ return locations.every((location) => sameSpecFile(token, location)) ? '' : whole;
318
+ });
319
+ return `${head}${kept}`;
320
+ }
174
321
  /**
175
322
  * The run in flight that makes a second run a hazard, or null when nothing is in the way.
176
323
  *
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAgJ9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAiOD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAmEnE;;;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;AAgDD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAu2ExF"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAiJ9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAkOD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAmEnE;;;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;AA8ED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAk6ExF"}
package/dist/tools.js CHANGED
@@ -9,6 +9,7 @@
9
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
+ import { pipelineExitCodeNote } from './pipeline-exit-note.js';
12
13
  import { TOOL_SCHEMAS } from './schemas.js';
13
14
  import { leadingSleepPeek } from './sleep-peek.js';
14
15
  /**
@@ -358,6 +359,7 @@ async function backgroundTaskState(backend, command, seconds, ceilingMs) {
358
359
  slice,
359
360
  };
360
361
  }
362
+ import { suiteCommandTarget } from './suite-overlap.js';
361
363
  import { diagnosisTraceCommand } from './trace-artifacts.js';
362
364
  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';
363
365
  /**
@@ -577,6 +579,35 @@ function batchFailureError(path, total, failures) {
577
579
  ? `\n\n(${omitted} more replacement${omitted === 1 ? '' : 's'} also failed and are not shown.)`
578
580
  : ''));
579
581
  }
582
+ /**
583
+ * Delays between retries of the symlink guard while a sandbox's command channel is
584
+ * still attaching: four attempts over roughly 2.6 s (plus the commands' own time),
585
+ * which covers the span a freshly claimed E2B sandbox answers `SandboxNotFoundError`
586
+ * before its exec channel is usable (xr-molecule-flash-m120 +00:29). A plain slow
587
+ * answer keeps the single 300 ms retry instead — a sandbox that is gone does not
588
+ * come back inside five seconds.
589
+ */
590
+ const SANDBOX_STARTING_BACKOFF_MS = [300, 800, 1_500];
591
+ /**
592
+ * Whether an error from the sandbox's command channel says the sandbox is still
593
+ * STARTING rather than gone for good.
594
+ *
595
+ * A sandbox created seconds earlier answers `SandboxNotFoundError` ("Sandbox is
596
+ * probably not running anymore") until its channel attaches, and that message names
597
+ * the worse of the two causes — so a file tool refused it as if the sandbox were
598
+ * dead. These shapes are retried; anything else keeps the one quick retry.
599
+ *
600
+ * @param error - The error thrown by `backend.run`.
601
+ * @returns True when the sandbox looks like it is still coming up.
602
+ */
603
+ function sandboxStillStarting(error) {
604
+ const err = error;
605
+ const name = typeof err?.name === 'string' ? err.name : '';
606
+ const message = typeof err?.message === 'string' ? err.message : '';
607
+ if (/^SandboxNotFound(?:Error)?$/i.test(name))
608
+ return true;
609
+ return /not (?:found|running)|notfound|not attached|still starting/i.test(`${name} ${message}`);
610
+ }
580
611
  /**
581
612
  * Build a complete set of AI agent tools bound to an execution backend.
582
613
  *
@@ -710,10 +741,18 @@ export function buildTools(backend, config) {
710
741
  async function checkSymlink(path) {
711
742
  if (!symlinkGuards)
712
743
  return null;
713
- // One retry: the first commands on a just-attached sandbox can fail at once (X1
714
- // flash-m44: three paths refused in 332 ms, the same check passing a beat later).
744
+ // The retry budget is failure-aware. A plain slow answer gets ONE quick retry
745
+ // (X1 flash-m44: three paths refused in 332 ms, the same check passing a beat
746
+ // later). A channel that is still ATTACHING gets longer: a sandbox created
747
+ // seconds earlier answers `SandboxNotFoundError` for well over 600 ms, so the
748
+ // fixed 2 × 300 ms budget ran out and the run's FIRST file tool was refused with
749
+ // "probably not running anymore" 300 ms before the same tool answered
750
+ // (xr-molecule-flash-m120 +00:29). Those errors are about a sandbox that is
751
+ // still starting, not one that is gone, so they are retried up to ~5 s.
715
752
  let lastError;
716
- for (let attempt = 0; attempt < 2; attempt++) {
753
+ let attempts = 0;
754
+ for (;;) {
755
+ attempts++;
717
756
  const started = Date.now();
718
757
  try {
719
758
  const result = await backend.run(`readlink -f ${shellQuote(path)} 2>/dev/null || echo ${shellQuote(path)}`, { timeout: 5000 });
@@ -727,20 +766,29 @@ export function buildTools(backend, config) {
727
766
  lastError = Object.assign(error instanceof Error ? error : new Error(String(error)), {
728
767
  elapsedMs: Date.now() - started,
729
768
  });
730
- if (attempt === 0)
731
- await new Promise((resolve) => setTimeout(resolve, 300));
769
+ const backoff = sandboxStillStarting(lastError) ? SANDBOX_STARTING_BACKOFF_MS : [];
770
+ const wait = backoff[attempts - 1] ?? (attempts === 1 ? 300 : undefined);
771
+ if (wait === undefined)
772
+ break;
773
+ await new Promise((resolve) => setTimeout(resolve, wait));
732
774
  }
733
775
  }
734
776
  // Deny conservatively, and say what failed: "access denied" read as a forbidden path,
735
777
  // and a fixed "did not answer within 5 s" hid an error that took 332 ms.
736
778
  const err = lastError;
737
779
  const timedOut = err?.name === 'TimeoutError' || (err?.elapsedMs ?? 0) >= 4900;
738
- return timedOut
739
- ? 'Unable to verify path safety: the sandbox did not answer the check within 5 s (it may be ' +
780
+ if (timedOut) {
781
+ return ('Unable to verify path safety: the sandbox did not answer the check within 5 s (it may be ' +
740
782
  'restarting or out of memory), so the path was refused to stay safe. Retry in a moment; if ' +
741
- 'every file tool fails this way, the sandbox needs a restart.'
742
- : `Unable to verify path safety: the sandbox's command channel failed (${err?.name ?? 'Error'}: ${String(err?.message ?? err).slice(0, 160)}), ` +
743
- 'so the path was refused to stay safe. Retry the call; if every file tool fails this way, the sandbox needs a restart.';
783
+ 'every file tool fails this way, the sandbox needs a restart.');
784
+ }
785
+ if (sandboxStillStarting(err)) {
786
+ return ('Unable to verify path safety: the sandbox is still starting (its command channel is not ' +
787
+ 'attached yet), so the path was refused to stay safe. Retry in a few seconds — a freshly ' +
788
+ 'created sandbox can take a moment to attach.');
789
+ }
790
+ return (`Unable to verify path safety: the sandbox's command channel failed (${err?.name ?? 'Error'}: ${String(err?.message ?? err).slice(0, 160)}), ` +
791
+ 'so the path was refused to stay safe. Retry the call; if every file tool fails this way, the sandbox needs a restart.');
744
792
  }
745
793
  /**
746
794
  * Strip control characters and optionally redact secrets from tool output.
@@ -2172,6 +2220,12 @@ export function buildTools(backend, config) {
2172
2220
  const placement = suiteRunPipelinePlacement(streaming.command);
2173
2221
  const reported = suiteRunExitCode(code, output, placement.piped);
2174
2222
  const exitNote = suiteExitCodeNote(placement, reported);
2223
+ // The same question the foreground path asks: when the code is a filter's, say
2224
+ // which stage produced it (and what the output carries) rather than leaving a
2225
+ // 0 beside a printed error.
2226
+ const pipeNote = reported === null
2227
+ ? null
2228
+ : pipelineExitCodeNote(streaming.command, reported, output);
2175
2229
  const notes = [
2176
2230
  ...(sleepNote ? [sleepNote] : []),
2177
2231
  ...streaming.changes,
@@ -2181,6 +2235,7 @@ export function buildTools(backend, config) {
2181
2235
  stdout,
2182
2236
  stderr: '',
2183
2237
  exitCode: reported,
2238
+ ...(pipeNote === null ? {} : { exitCodeNote: pipeNote }),
2184
2239
  ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
2185
2240
  ...(notes.length ? { note: notes.join(' ') } : {}),
2186
2241
  };
@@ -2305,10 +2360,16 @@ export function buildTools(backend, config) {
2305
2360
  (isSuiteRunCommand(command) ? SUITE_OVER_BUDGET_ADVICE : SPLIT_WORK_ADVICE),
2306
2361
  };
2307
2362
  }
2363
+ // A pipeline reports its LAST stage's code: `tsc | head` says 0 beside a printed type
2364
+ // error, and `tsc | head && tail missing.log` says 1 beside a clean check. Name the
2365
+ // stage the number came from (pipeline-exit-note.ts) — the field the executor trusts
2366
+ // most must not contradict the text it can read.
2367
+ const exitCodeNote = pipelineExitCodeNote(command, result.exitCode, `${result.stdout}\n${result.stderr}`);
2308
2368
  return {
2309
2369
  stdout,
2310
2370
  stderr,
2311
2371
  exitCode: result.exitCode,
2372
+ ...(exitCodeNote === null ? {} : { exitCodeNote }),
2312
2373
  ...(peekedTask?.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2313
2374
  ...(sleepNote || ceilingNote || traced.change
2314
2375
  ? {
@@ -2492,6 +2553,13 @@ export function buildTools(backend, config) {
2492
2553
  // note it printed called "a lead, not a cause" — and read the early return as "stop waiting,
2493
2554
  // edit now", voiding a run that was still walking both times (the second wait, 224 s of a
2494
2555
  // 280 s budget, ended with nothing at all).
2556
+ // How this run was started, so the note can hand over the re-run to send once it ends
2557
+ // instead of one for now — the platform refuses a second run against the same app and
2558
+ // database while this one walks (xr-molecule-flash-m89: the +19:59 note named a re-run
2559
+ // and its overlap hold refused that exact run 23 s later) — and so a run narrowed to a
2560
+ // file or two is judged against what it selected (xr-molecule-flash-m119 +42:08). Read
2561
+ // BEFORE the loop: the running answers below are built after it closes and still need it.
2562
+ const started = runByTask.get(taskId);
2495
2563
  let midRunNote = null;
2496
2564
  let lastCauseLookAt = startedAt - repeatingCauseLookMs();
2497
2565
  let exitCode = await readExit();
@@ -2523,12 +2591,7 @@ export function buildTools(backend, config) {
2523
2591
  continue;
2524
2592
  // The ONE predicate behind this decision and `repeatingFailureNote`'s wording, so the two
2525
2593
  // cannot come to different verdicts.
2526
- const stopCause = earlyReturnCause(found, universalCause(causeLog, found));
2527
- // How this run was started, so the note can hand over the re-run to send once it ends
2528
- // instead of one for now — the platform refuses a second run against the same app and
2529
- // database while this one walks (xr-molecule-flash-m89: the +19:59 note named a re-run
2530
- // and its overlap hold refused that exact run 23 s later).
2531
- const started = runByTask.get(taskId);
2594
+ const stopCause = earlyReturnCause(found, universalCause(causeLog, found, started?.command));
2532
2595
  if (stopCause) {
2533
2596
  // …and only now is the pid walk paid for.
2534
2597
  repeatingCauseNoted.add(taskId);
@@ -2553,12 +2616,18 @@ export function buildTools(backend, config) {
2553
2616
  // duration into a proven whole-run cause, and that one is worth returning early for.
2554
2617
  if (!midRunLeadNoted.has(taskId)) {
2555
2618
  midRunLeadNoted.add(taskId);
2619
+ // A run against the sandbox's own app is changed by an edit while it walks — the two
2620
+ // instructions the note and the wait's own sentence give must agree (xr-molecule-flash-
2621
+ // m122 +19:00: one result said editing app/src "changes the code the run is testing" and
2622
+ // "Start fixing it now"). The target is read off the command the task was started with.
2623
+ const editsInvalidateRun = started === undefined || suiteCommandTarget(started.command) === 'local';
2556
2624
  midRunNote = repeatingFailureNote(found, {
2557
2625
  taskId,
2558
2626
  log,
2559
2627
  stop: '',
2560
2628
  universal: null,
2561
2629
  ...(started ? { run: started } : {}),
2630
+ ...(editsInvalidateRun ? { editsInvalidateRun: true } : {}),
2562
2631
  });
2563
2632
  }
2564
2633
  }
@@ -2649,7 +2718,16 @@ export function buildTools(backend, config) {
2649
2718
  `each failure's own report into the log as that test fails, right under its ✘ line, ` +
2650
2719
  `not only in the summary at the end. `
2651
2720
  : `Nothing has been printed yet — \`grep -n '✘' ${log} | head -40\` lists failures as they appear. `) +
2652
- `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}`,
2721
+ // What an edit does to this run depends on what the run MEASURES: the sandbox's own
2722
+ // app is re-served from app/src as it is edited, so the run's result stops counting; a
2723
+ // run against the deployed build is untouched by a local edit, and the executor can
2724
+ // spend the rest of the run fixing (xr-molecule-flash-m122 +19:00: a deployed run was
2725
+ // told editing changes what it tests, then handed "Start fixing it now" in the same
2726
+ // result — it waited out 1 min 11 s of free fixing time).
2727
+ (started !== undefined && suiteCommandTarget(started.command) !== 'local'
2728
+ ? `Meanwhile fix what it has already named: this run tests the deployed build, so editing app/src or api/src now does not change what it tests — fix the causes it has already named while it walks, run nothing against the app until it ends, then deploy once.`
2729
+ : `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.`) +
2730
+ `${midRun}`,
2653
2731
  };
2654
2732
  }
2655
2733
  let output;
@@ -54,6 +54,12 @@ export interface TraceRequest {
54
54
  sent?: string;
55
55
  /** What came back for that 4xx, bounded the same way — the API's own error body. */
56
56
  got?: string;
57
+ /**
58
+ * The browser's own resource type (`document`, `fetch`, `xhr`, `font`, `image`, `stylesheet`,
59
+ * `script`, …). `document` when the request was the page load itself; when it ended in a
60
+ * `net::ERR_*`, nothing rendered and no selector or app request was measured.
61
+ */
62
+ type?: string;
57
63
  }
58
64
  /** One action the traced page performed, with the trace's own timing for it. */
59
65
  export interface TraceAction {
@@ -137,6 +143,36 @@ export declare const traceArtifactsScript: () => string;
137
143
  * @returns One entry per trace it printed.
138
144
  */
139
145
  export declare function parseTraceArtifacts(stdout: string): TraceArtifact[];
146
+ /**
147
+ * Whether a request is one of the page shell's, not the test's (`STATIC_ASSET_PATH`).
148
+ *
149
+ * The document itself is never incidental — a page that never loaded IS the failure — and a
150
+ * request whose type is the app's own API (`fetch`/`xhr`) is judged by its path alone.
151
+ *
152
+ * @param request - One request a trace recorded.
153
+ * @returns True when the request is incidental to the step under test.
154
+ */
155
+ export declare function isStaticAssetRequest(request: TraceRequest): boolean;
156
+ /**
157
+ * Whether a trace recorded a request the server actually failed: a 4xx/5xx, or a network error.
158
+ *
159
+ * A 404 on a static asset is excepted: `/sw.js` and `/fonts/*` 404 on an app that never shipped
160
+ * them, and naming one as a failure sent m125 into writing the caching worker it had asked about.
161
+ * A 5xx on an asset, or a network error, still counts — the server did fail.
162
+ *
163
+ * @param request - One request a trace recorded.
164
+ * @returns True when the request failed in a way the note should name.
165
+ */
166
+ export declare function failedRequest(request: TraceRequest): boolean;
167
+ /**
168
+ * Whether a trace recorded NO response and no reason for it — a request that was aborted or still
169
+ * in flight when the test ended. It is not evidence that the server failed (m112 +30:45), and one
170
+ * the page shell made is not evidence about the step at all (m125 +29:33).
171
+ *
172
+ * @param request - One request a trace recorded.
173
+ * @returns True when the request is still in flight and worth naming.
174
+ */
175
+ export declare function unansweredRequest(request: TraceRequest): boolean;
140
176
  /**
141
177
  * The note a failed suite run's result carries: what each failing test's page actually sent, and
142
178
  * what came back.
@@ -164,6 +200,10 @@ export declare function parseTraceArtifacts(stdout: string): TraceArtifact[];
164
200
  * traces of one `afterEach` line whose every request was `-1`, and left the favorite cause — the
165
201
  * one failure that repeated in every walk — in its "… and N more".
166
202
  *
203
+ * A run whose every traced page never finished its navigation leads with the browser's own
204
+ * transport error (`net::ERR_CONTENT_DECODING_FAILED` and friends) and the document it hit: nothing
205
+ * rendered, so no row below is app evidence (xr-molecule-flash-m118 +37:09).
206
+ *
167
207
  * @param items - The traces {@link parseTraceArtifacts} read.
168
208
  * @param target - Where the run went — the deployed host, as the note names it.
169
209
  * @param earlierFailures - The failing `file:line`s earlier walks of this suite left, so a repeat
@@ -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;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAA;IACd;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,oFAAoF;IACpF,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED,gFAAgF;AAChF,MAAM,WAAW,WAAW;IAC1B,yFAAyF;IACzF,GAAG,EAAE,MAAM,CAAA;IACX,wEAAwE;IACxE,KAAK,EAAE,MAAM,CAAA;IACb,kEAAkE;IAClE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CACnB;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;IACjB;;;;OAIG;IACH,OAAO,EAAE,WAAW,EAAE,CAAA;CACvB;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,sFAAsF;AACtF,eAAO,MAAM,kBAAkB,KAAK,CAAA;AAEpC,yFAAyF;AACzF,eAAO,MAAM,qBAAqB,MAAM,CAAA;AAExC,uDAAuD;AACvD,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,iDAAiD;AACjD,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,gEAAgE;AAChE,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC,wFAAwF;AACxF,eAAO,MAAM,qBAAqB,MAAM,CAAA;AAExC,kFAAkF;AAClF,eAAO,MAAM,oBAAoB,MAAM,CAAA;AAKvC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,oBAAoB,QAAO,MAiQvC,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAqCnE;AAwJD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,SAAS,aAAa,EAAE,EAC/B,MAAM,EAAE,MAAM,EACd,eAAe,GAAE,SAAS,MAAM,EAAO,GACtC,MAAM,GAAG,IAAI,CAmFf;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"}
1
+ {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"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"}