specrails-core 6.6.2 → 6.6.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.
@@ -755,7 +755,7 @@ export async function runCompactDeveloper(env, options = {}) {
755
755
  files.add(file);
756
756
  // Manifests written by this group get their dependencies installed by the host, so verify checks the code and not the environment.
757
757
  if (on(env, 'environment-repair') && [...groupFiles].some(file => /(?:^|[\\/])(?:package\.json|requirements\.txt|pyproject\.toml|go\.mod|Cargo\.toml)$/.test(file)))
758
- for (const outcome of installEnvironment(env.toolset.roots, { onEvent: env.onEvent, lockfileRepair: on(env, 'lockfile-repair') }))
758
+ for (const outcome of await installEnvironment(env.toolset.roots, { onEvent: env.onEvent, lockfileRepair: on(env, 'lockfile-repair') }))
759
759
  verification.push(outcome.detail);
760
760
  for (const file of strings(result.tests, 100, 500))
761
761
  tests.add(file);
@@ -765,7 +765,7 @@ export async function runCompactDeveloper(env, options = {}) {
765
765
  // SAME group fix a failure while its context is fresh (one bounded round),
766
766
  // instead of discovering five broken suites at the end of the step.
767
767
  if (on(env, 'verify-per-group') && groupFiles.size && !correction) {
768
- const check = runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
768
+ const check = await runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
769
769
  if (check.ran && !check.ok) {
770
770
  env.onEvent?.({ kind: 'text', text: `Group ${group.index} check failed (${check.command}); fixing in place before the next group.` });
771
771
  const fixed = await fixGroupInPlace(env, group, check.output, budget, evidence, extraTools, languages);
@@ -773,7 +773,7 @@ export async function runCompactDeveloper(env, options = {}) {
773
773
  files.add(file);
774
774
  groupFiles.add(file);
775
775
  }
776
- const again = runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
776
+ const again = await runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
777
777
  env.onEvent?.({ kind: 'text', text: again.ok ? `Group ${group.index} check passed after the fix.` : `Group ${group.index} check still failing (${again.command}); the host verify will report it.` });
778
778
  verification.push(`group ${group.index} check ${again.ok ? 'passed' : 'failed'}: ${again.command ?? ''}`);
779
779
  }
@@ -793,7 +793,7 @@ export async function runCompactDeveloper(env, options = {}) {
793
793
  files.add(file);
794
794
  groupFiles.add(file);
795
795
  }
796
- const again = runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
796
+ const again = await runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
797
797
  const still = unreachedGroupTests(env.toolset.roots, unreached);
798
798
  env.onEvent?.({ kind: 'text', text: still.length ? `Group ${group.index}: ${still.join(', ')} still not run by the test command; the host verify will report it.` : again.ok ? `Group ${group.index}: tests wired in and the check passed.` : `Group ${group.index}: tests wired in but the check now fails (${again.command}); the host verify will report it.` });
799
799
  verification.push(`group ${group.index} test reachability ${still.length ? `unresolved: ${still.join(', ')}` : 'ok'}${again.ran && !again.ok ? `; check failed: ${again.command ?? ''}` : ''}`);
@@ -1,4 +1,20 @@
1
- import type { spawnSync } from 'node:child_process';
1
+ import type { SpawnSyncOptionsWithStringEncoding, SpawnSyncReturns } from 'node:child_process';
2
+ /** The parts of a finished host command the environment logic reads. */
3
+ export interface HostRun {
4
+ status: number | null;
5
+ signal: NodeJS.Signals | null;
6
+ stdout: string;
7
+ stderr: string;
8
+ error?: Error;
9
+ }
10
+ /** Runs one host command. Injectable for tests; a synchronous result is accepted too. */
11
+ export type HostSpawn = (command: string, args: readonly string[], options: SpawnSyncOptionsWithStringEncoding) => HostRun | SpawnSyncReturns<string> | Promise<HostRun>;
12
+ /**
13
+ * Async counterpart of `spawnSync` with the same result shape: `status` null
14
+ * and an `ETIMEDOUT` error after `timeout` (the child is sent `killSignal`),
15
+ * stdout/stderr kept as bounded tails instead of failing with ENOBUFS.
16
+ */
17
+ export declare const hostSpawn: HostSpawn;
2
18
  /** `timeoutMs` overrides the default bound for one plan (a browser download is far larger than a dependency install). `drift`: installed direct dependencies the install must bring back inside their declared ranges. */
3
19
  export interface EnvironmentInstall {
4
20
  ecosystem: 'node' | 'python' | 'go' | 'rust';
@@ -66,7 +82,7 @@ export declare function describeDrift(drift: readonly DriftedDependency[]): stri
66
82
  export declare function plannedInstalls(root: string, failureOutput?: string): EnvironmentInstall[];
67
83
  /** Runs each planned install once, bounded; never throws. */
68
84
  export interface InstallIo {
69
- spawn?: typeof spawnSync;
85
+ spawn?: HostSpawn;
70
86
  timeoutMs?: number;
71
87
  failureOutput?: string;
72
88
  lockfileRepair?: boolean;
@@ -79,7 +95,7 @@ export interface InstallIo {
79
95
  /** The installs for one root; the failure-driven plan by default. */
80
96
  plan?: (root: string) => EnvironmentInstall[];
81
97
  }
82
- export declare function installEnvironment(roots: readonly string[], io?: InstallIo): InstallOutcome[];
98
+ export declare function installEnvironment(roots: readonly string[], io?: InstallIo): Promise<InstallOutcome[]>;
83
99
  type PlaywrightBrowser = 'chromium' | 'firefox' | 'webkit';
84
100
  /** Browsers the repository's Playwright config runs (by project names, devices or browserName); chromium when it names none. */
85
101
  export declare function configuredPlaywrightBrowsers(root: string): PlaywrightBrowser[];
@@ -89,7 +105,7 @@ export declare function configuredPlaywrightBrowsers(root: string): PlaywrightBr
89
105
  * locations without downloading; an unreadable answer means "unknown", never
90
106
  * an install.
91
107
  */
92
- export declare function missingPlaywrightBrowsers(root: string, spawn?: typeof spawnSync): PlaywrightBrowser[];
108
+ export declare function missingPlaywrightBrowsers(root: string, spawn?: HostSpawn): Promise<PlaywrightBrowser[]>;
93
109
  /**
94
110
  * Host preparation before an agent edits a repository: the dependencies its
95
111
  * manifest declares but are not installed, then the browser builds its
@@ -98,7 +114,7 @@ export declare function missingPlaywrightBrowsers(root: string, spawn?: typeof s
98
114
  * idempotent, project-local work happens here; other ecosystems keep their
99
115
  * failure-driven repair at verification.
100
116
  */
101
- export declare function prepareEnvironment(roots: readonly string[], io?: Omit<InstallIo, 'plan' | 'failureOutput'>): InstallOutcome[];
117
+ export declare function prepareEnvironment(roots: readonly string[], io?: Omit<InstallIo, 'plan' | 'failureOutput'>): Promise<InstallOutcome[]>;
102
118
  /**
103
119
  * A Node preload that answers IPv6 lookups with nothing when the host has an
104
120
  * IPv4 address, so dual-stack clients connect over IPv4. Written once to the
@@ -124,7 +140,7 @@ interface GroupCheckOutcome {
124
140
  * still fresh instead of surfacing as five failed suites at the end.
125
141
  */
126
142
  export declare function runGroupCheck(roots: readonly string[], io?: {
127
- spawn?: typeof spawnSync;
143
+ spawn?: HostSpawn;
128
144
  timeoutMs?: number;
129
145
  onEvent?: (event: {
130
146
  kind: 'tool-start' | 'tool-end' | 'text';
@@ -132,5 +148,5 @@ export declare function runGroupCheck(roots: readonly string[], io?: {
132
148
  detail?: string;
133
149
  text?: string;
134
150
  }) => void;
135
- }): GroupCheckOutcome;
151
+ }): Promise<GroupCheckOutcome>;
136
152
  export {};
@@ -3,10 +3,62 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import semver from 'semver';
6
- // npm/pnpm/yarn are `.cmd` shims on Windows: a bare `spawnSync('npm')` is
7
- // ENOENT there, which read as an environment failure of its own and sent the
8
- // install/check round in circles. cross-spawn resolves the shim on every OS.
9
- const defaultSpawn = crossSpawn.sync;
6
+ /** Bytes of each stream retained (the tail): installs can print far more than any caller reads. */
7
+ const OUTPUT_TAIL_BYTES = 4 * 1024 * 1024;
8
+ /**
9
+ * Async counterpart of `spawnSync` with the same result shape: `status` null
10
+ * and an `ETIMEDOUT` error after `timeout` (the child is sent `killSignal`),
11
+ * stdout/stderr kept as bounded tails instead of failing with ENOBUFS.
12
+ */
13
+ export const hostSpawn = (command, args, options) => new Promise(resolve => {
14
+ let settled = false, timedOut = false, stdout = '', stderr = '';
15
+ const limit = options.maxBuffer ?? OUTPUT_TAIL_BYTES;
16
+ const finish = (result) => {
17
+ if (settled)
18
+ return;
19
+ settled = true;
20
+ if (timer)
21
+ clearTimeout(timer);
22
+ resolve(result);
23
+ };
24
+ let child;
25
+ try {
26
+ child = crossSpawn(command, [...args], { cwd: options.cwd, env: options.env, windowsHide: options.windowsHide ?? true, stdio: ['ignore', 'pipe', 'pipe'] });
27
+ }
28
+ catch (error) {
29
+ resolve({ status: null, signal: null, stdout: '', stderr: '', error: error });
30
+ return;
31
+ }
32
+ const timer = options.timeout ? setTimeout(() => {
33
+ timedOut = true;
34
+ try {
35
+ child.kill(options.killSignal ?? 'SIGTERM');
36
+ }
37
+ catch { /* already gone */ }
38
+ }, options.timeout) : undefined;
39
+ const tail = (current, chunk) => {
40
+ const next = current + chunk;
41
+ return next.length > limit ? next.slice(next.length - limit) : next;
42
+ };
43
+ child.stdout?.setEncoding('utf8').on('data', (chunk) => { stdout = tail(stdout, chunk); });
44
+ child.stderr?.setEncoding('utf8').on('data', (chunk) => { stderr = tail(stderr, chunk); });
45
+ child.on('error', error => finish({ status: null, signal: null, stdout, stderr, error }));
46
+ child.on('close', (code, signal) => finish({
47
+ status: timedOut ? null : code, signal, stdout, stderr,
48
+ ...(timedOut ? { error: Object.assign(new Error(`spawn ${command} ETIMEDOUT`), { code: 'ETIMEDOUT' }) } : {}),
49
+ }));
50
+ });
51
+ const defaultSpawn = hostSpawn;
52
+ const failedRun = (error) => ({ status: null, signal: null, stdout: '', stderr: '', error: error });
53
+ async function runHost(spawn, command, args, options) {
54
+ try {
55
+ const result = await spawn(command, args, options);
56
+ return { status: result.status, signal: result.signal, stdout: String(result.stdout ?? ''), stderr: String(result.stderr ?? ''), ...(result.error ? { error: result.error } : {}) };
57
+ }
58
+ catch (error) {
59
+ return failedRun(error);
60
+ }
61
+ }
10
62
  // A Playwright test run on a host whose browser cache was never populated (a
11
63
  // fresh machine, a new Playwright version): the package is installed, the
12
64
  // browser build it pins is not. `playwright install` is the documented fix.
@@ -263,21 +315,14 @@ function relaxManifestPin(root, name, version) {
263
315
  writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n');
264
316
  return changed;
265
317
  }
266
- export function installEnvironment(roots, io = {}) {
318
+ export async function installEnvironment(roots, io = {}) {
267
319
  const spawn = io.spawn ?? defaultSpawn;
268
320
  const outcomes = [];
269
321
  for (const root of roots) {
270
322
  for (const plan of io.plan ? io.plan(root) : plannedInstalls(root, io.failureOutput ?? '')) {
271
323
  io.onEvent?.({ kind: 'tool-start', tool: plan.command, detail: `${plan.args.join(' ')} (${path.basename(root)})` });
272
- const run = () => {
273
- try {
274
- return spawn(plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: plan.timeoutMs ?? io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: process.env });
275
- }
276
- catch (error) {
277
- return { status: null, error: error, stdout: '', stderr: '', pid: 0, output: [], signal: null };
278
- }
279
- };
280
- let result = run();
324
+ const run = () => runHost(spawn, plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: plan.timeoutMs ?? io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: process.env });
325
+ let result = await run();
281
326
  // A model that writes package-lock.json by hand invents integrity hashes
282
327
  // (npm EINTEGRITY) or pins versions that never existed (ETARGET/E404 on a
283
328
  // locked entry). The lock is not candidate content: drop it and let npm
@@ -289,7 +334,7 @@ export function installEnvironment(roots, io = {}) {
289
334
  }
290
335
  catch { /* the retry below then repeats the failure honestly */ }
291
336
  io.onEvent?.({ kind: 'text', text: `Environment: ${path.basename(lock)} did not match the registry; removed it and retrying ${plan.command} ${plan.args.join(' ')}` });
292
- result = run();
337
+ result = await run();
293
338
  }
294
339
  // A pin the registry has never published ("No matching version found for
295
340
  // @babel/preset-env@7.23.0"): the model invented the version. Relax that
@@ -297,7 +342,7 @@ export function installEnvironment(roots, io = {}) {
297
342
  const unpublished = plan.ecosystem === 'node' && result.status !== 0 ? /No matching version found for ((?:@[\w.-]+\/)?[\w.-]+)@([^\s.]+(?:\.[^\s.]+)*)\.?(?:\n|$)/.exec(String(result.stderr)) : null;
298
343
  if (unpublished && relaxManifestPin(root, unpublished[1], unpublished[2])) {
299
344
  io.onEvent?.({ kind: 'text', text: `Environment: ${unpublished[1]}@${unpublished[2]} is not published; relaxed the pin in package.json and retrying ${plan.command} ${plan.args.join(' ')}` });
300
- result = run();
345
+ result = await run();
301
346
  }
302
347
  // Playwright's downloader tries IPv6 first; on a host whose DNS returns
303
348
  // an AAAA record without an IPv6 route, Node's family auto-selection
@@ -306,12 +351,7 @@ export function installEnvironment(roots, io = {}) {
306
351
  const preload = ipv4FirstPreload();
307
352
  if (preload) {
308
353
  io.onEvent?.({ kind: 'text', text: `Environment: the browser download timed out; retrying ${plan.command} ${plan.args.join(' ')} over IPv4` });
309
- try {
310
- result = spawn(plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: plan.timeoutMs ?? io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --require ${JSON.stringify(preload)}`.trim() } });
311
- }
312
- catch (error) {
313
- result = { status: null, error: error, stdout: '', stderr: '', pid: 0, output: [], signal: null };
314
- }
354
+ result = await runHost(spawn, plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: plan.timeoutMs ?? io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --require ${JSON.stringify(preload)}`.trim() } });
315
355
  }
316
356
  }
317
357
  io.onEvent?.({ kind: 'tool-end', tool: plan.command });
@@ -365,20 +405,14 @@ function playwrightCli(root) {
365
405
  * locations without downloading; an unreadable answer means "unknown", never
366
406
  * an install.
367
407
  */
368
- export function missingPlaywrightBrowsers(root, spawn = defaultSpawn) {
408
+ export async function missingPlaywrightBrowsers(root, spawn = defaultSpawn) {
369
409
  if (!declaresPlaywright(root))
370
410
  return [];
371
411
  const cli = playwrightCli(root);
372
412
  if (!cli)
373
413
  return [];
374
414
  const browsers = configuredPlaywrightBrowsers(root);
375
- let result;
376
- try {
377
- result = spawn(cli.command, [...cli.args, 'install', '--dry-run', ...browsers], { cwd: root, encoding: 'utf8', timeout: 30_000, windowsHide: true, env: process.env });
378
- }
379
- catch {
380
- return [];
381
- }
415
+ const result = await runHost(spawn, cli.command, [...cli.args, 'install', '--dry-run', ...browsers], { cwd: root, encoding: 'utf8', timeout: 30_000, windowsHide: true, env: process.env });
382
416
  if (result.status !== 0)
383
417
  return [];
384
418
  const locations = [...String(result.stdout ?? '').matchAll(/Install location:\s+(.+?)\s*$/gm)].map(match => match[1]);
@@ -392,14 +426,17 @@ export function missingPlaywrightBrowsers(root, spawn = defaultSpawn) {
392
426
  * idempotent, project-local work happens here; other ecosystems keep their
393
427
  * failure-driven repair at verification.
394
428
  */
395
- export function prepareEnvironment(roots, io = {}) {
429
+ export async function prepareEnvironment(roots, io = {}) {
396
430
  const spawn = io.spawn ?? defaultSpawn;
397
- const dependencies = installEnvironment(roots, { ...io, plan: root => plannedInstalls(root).filter(plan => plan.ecosystem === 'node') });
398
- const browsers = installEnvironment(roots, { ...io, plan: root => {
399
- const missing = missingPlaywrightBrowsers(root, spawn);
400
- const cli = missing.length ? playwrightCli(root) : undefined;
401
- return cli ? [{ ecosystem: 'node', root, command: cli.command, args: [...cli.args, 'install', ...missing], timeoutMs: PLAYWRIGHT_INSTALL_TIMEOUT_MS, installs: `Playwright ${missing.join(', ')}` }] : [];
402
- } });
431
+ const dependencies = await installEnvironment(roots, { ...io, plan: root => plannedInstalls(root).filter(plan => plan.ecosystem === 'node') });
432
+ // The browser plan needs the async dry-run, so it is resolved per root first.
433
+ const browserPlans = new Map();
434
+ for (const root of roots) {
435
+ const missing = await missingPlaywrightBrowsers(root, spawn);
436
+ const cli = missing.length ? playwrightCli(root) : undefined;
437
+ browserPlans.set(root, cli ? [{ ecosystem: 'node', root, command: cli.command, args: [...cli.args, 'install', ...missing], timeoutMs: PLAYWRIGHT_INSTALL_TIMEOUT_MS, installs: `Playwright ${missing.join(', ')}` }] : []);
438
+ }
439
+ const browsers = await installEnvironment(roots, { ...io, plan: root => browserPlans.get(root) ?? [] });
403
440
  return [...dependencies, ...browsers];
404
441
  }
405
442
  const NETWORK_TIMEOUT = /timed out after \d+ ?ms|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH/i;
@@ -460,7 +497,7 @@ export function detectCheckCommand(root) {
460
497
  * groups (`verify-per-group`) so a broken group is fixed while its context is
461
498
  * still fresh instead of surfacing as five failed suites at the end.
462
499
  */
463
- export function runGroupCheck(roots, io = {}) {
500
+ export async function runGroupCheck(roots, io = {}) {
464
501
  const spawn = io.spawn ?? defaultSpawn;
465
502
  for (const root of roots) {
466
503
  const check = detectCheckCommand(root);
@@ -468,13 +505,7 @@ export function runGroupCheck(roots, io = {}) {
468
505
  continue;
469
506
  const label = `${check.command} ${check.args.join(' ')}`;
470
507
  io.onEvent?.({ kind: 'tool-start', tool: check.command, detail: `${check.args.join(' ')} (${path.basename(root)}, group check)` });
471
- let result;
472
- try {
473
- result = spawn(check.command, check.args, { cwd: root, encoding: 'utf8', timeout: io.timeoutMs ?? 3 * 60_000, killSignal: 'SIGKILL', windowsHide: true, env: { ...process.env, CI: '1', FORCE_COLOR: '0' }, maxBuffer: 8 * 1024 * 1024 });
474
- }
475
- catch (error) {
476
- result = { status: null, error: error, stdout: '', stderr: '', pid: 0, output: [], signal: null };
477
- }
508
+ const result = await runHost(spawn, check.command, check.args, { cwd: root, encoding: 'utf8', timeout: io.timeoutMs ?? 3 * 60_000, killSignal: 'SIGKILL', windowsHide: true, env: { ...process.env, CI: '1', FORCE_COLOR: '0' }, maxBuffer: 8 * 1024 * 1024 });
478
509
  io.onEvent?.({ kind: 'tool-end', tool: check.command });
479
510
  const timedOut = result.error?.message?.includes('ETIMEDOUT') || result.signal === 'SIGKILL';
480
511
  const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.replace(/\u001b\[[0-9;]*m/g, '').trim().slice(-6000);
@@ -35,7 +35,7 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
35
35
  const note = (line) => context.progress({ type: 'verification-output', payload: { text: line } });
36
36
  const environment = options.environmentRetry
37
37
  ? environmentNote(options.environmentRetry.installs, 'retry')
38
- : hostRepairs ? environmentNote(prepareEnvironment(deps.context.repositories.map(repository => repository.path), environmentIo(deps, note)), 'prepared') : '';
38
+ : hostRepairs ? environmentNote(await prepareEnvironment(deps.context.repositories.map(repository => repository.path), environmentIo(deps, note)), 'prepared') : '';
39
39
  const schema = params.structuredOutput;
40
40
  const validate = schema ? ajv.compile(schema) : undefined;
41
41
  const roleState = deps.roleState(context);
@@ -79,7 +79,7 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
79
79
  if (blocker) {
80
80
  const roots = deps.context.repositories.map(repository => repository.path);
81
81
  const io = environmentIo(deps, note);
82
- const installs = [...installEnvironment(roots, { ...io, failureOutput: blocker }), ...prepareEnvironment(roots, io)];
82
+ const installs = [...await installEnvironment(roots, { ...io, failureOutput: blocker }), ...await prepareEnvironment(roots, io)];
83
83
  if (installs.some(install => install.ok)) {
84
84
  note('[environment] The agent reported a missing dependency or tool; the host installed it and runs the turn again.');
85
85
  return executeRoleTurn(deps, params, context, { ...options, environmentRetry: { installs } });
@@ -177,7 +177,7 @@ export function verifyPiece(bindings) {
177
177
  const drift = environment.drift.flatMap(item => item.drift.map(entry => ({ root: checkoutRelative(deps.context, item.root), ...entry }))).slice(0, 20);
178
178
  if (drift.length)
179
179
  progress(`[environment] Installed dependencies do not satisfy their declared ranges (${describeDrift(drift)}); the host reinstalls them before judging the failure.`);
180
- const repair = repairEnvironment(deps.context, receipt, deps.config.guardrails, progress);
180
+ const repair = await repairEnvironment(deps.context, receipt, deps.config.guardrails, progress);
181
181
  environmentRepair = { attempted: true, reverified: false, installs: repair.installs.map(install => ({ command: install.command, args: install.args, root: checkoutRelative(deps.context, install.root), ok: install.ok, detail: boundedText(install.detail, 1_000) })), ...(drift.length ? { drift } : {}) };
182
182
  if (repair.refused)
183
183
  blocker = repair.refused;
@@ -356,7 +356,7 @@ function verifyNode(deps) {
356
356
  const drift = environment.drift.flatMap(item => item.drift);
357
357
  if (drift.length)
358
358
  note('developer', `Installed dependencies do not satisfy their declared ranges (${describeDrift(drift)}); the host reinstalls them before judging the failure.`);
359
- const repair = repairEnvironment(context, receipt, config.guardrails, text => note('developer', text));
359
+ const repair = await repairEnvironment(context, receipt, config.guardrails, text => note('developer', text));
360
360
  if (repair.refused)
361
361
  return hostBlocked(blockerMessage(repair.refused));
362
362
  if (repair.installs.some(outcome => outcome.ok)) {
@@ -1,6 +1,5 @@
1
- import type { spawnSync } from 'node:child_process';
2
1
  import { type CommandReceipt, type PipelineContext, type VerificationReceipt, type VerificationSelfMutation } from '../pipeline/pipeline-state.js';
3
- import { type DriftedDependency, type InstallOutcome } from './compact/environment.js';
2
+ import { type DriftedDependency, type HostSpawn, type InstallOutcome } from './compact/environment.js';
4
3
  import type { HostBlocker } from './engine/contracts.js';
5
4
  import { type GuardrailSettings } from './guardrails.js';
6
5
  export type { HostBlocker } from './engine/contracts.js';
@@ -50,6 +49,6 @@ export interface EnvironmentRepair {
50
49
  * install (app)`, `Environment: installed …`). An install the host cannot
51
50
  * complete for a precondition reason becomes `refused`.
52
51
  */
53
- export declare function repairEnvironment(context: PipelineContext, receipt: VerificationReceipt, guardrails: GuardrailSettings | undefined, note: (text: string) => void, spawn?: typeof spawnSync): EnvironmentRepair;
52
+ export declare function repairEnvironment(context: PipelineContext, receipt: VerificationReceipt, guardrails: GuardrailSettings | undefined, note: (text: string) => void, spawn?: HostSpawn): Promise<EnvironmentRepair>;
54
53
  /** A blocker as a durable JSON value: known kind, bounded strings, at most 16 arguments. */
55
54
  export declare function boundedBlocker(value: unknown): HostBlocker | undefined;
@@ -1,3 +1,11 @@
1
+ // Host-owned repair of a failed verification, shared by the legacy graph
2
+ // (`graph/nodes.ts`) and the engine v2 `verify` piece.
3
+ //
4
+ // A failed check is not always a defect in the change: a fresh worktree has no
5
+ // node_modules, a new machine has no Playwright browser, a registry token has
6
+ // expired. The host classifies the failure first (a precondition it cannot
7
+ // satisfy becomes a structured blocker), installs what it can once, and only a
8
+ // failure that survives that becomes correction feedback.
1
9
  import { existsSync } from 'node:fs';
2
10
  import path from 'node:path';
3
11
  import { describeModifiedFiles } from '../pipeline/pipeline-state.js';
@@ -112,10 +120,10 @@ export function adoptedOutputNote(mutation) {
112
120
  * install (app)`, `Environment: installed …`). An install the host cannot
113
121
  * complete for a precondition reason becomes `refused`.
114
122
  */
115
- export function repairEnvironment(context, receipt, guardrails, note, spawn) {
123
+ export async function repairEnvironment(context, receipt, guardrails, note, spawn) {
116
124
  const failed = receipt.commands.filter(command => command.exitCode !== 0);
117
125
  const roots = [...new Set([...installRoots(context, failed), ...driftedFailureRoots(context, receipt).map(item => item.root)])];
118
- const installs = installEnvironment(roots, { failureOutput: receipt.commands.map(command => command.output).join('\n'), lockfileRepair: guardrailEnabled(guardrails, 'lockfile-repair'),
126
+ const installs = await installEnvironment(roots, { failureOutput: receipt.commands.map(command => command.output).join('\n'), lockfileRepair: guardrailEnabled(guardrails, 'lockfile-repair'),
119
127
  ...(spawn ? { spawn } : {}), onEvent: event => note(event.kind === 'text' ? event.text ?? '' : `[environment] ${event.tool ?? ''} ${event.detail ?? ''}`.trim()) });
120
128
  const refused = installs.find(outcome => outcome.precondition);
121
129
  if (!refused)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-core",
3
- "version": "6.6.2",
3
+ "version": "6.6.3",
4
4
  "description": "Agent workflow engine for specrails-desktop: provider-independent installer and programmatic agent runtime for Claude Code and Codex",
5
5
  "type": "module",
6
6
  "exports": {