@haystackeditor/cli 0.20.0 → 0.22.0

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.
@@ -10,7 +10,7 @@
10
10
  import chalk from 'chalk';
11
11
  import { withSchema } from '../schema.js';
12
12
  import { resolveAuthContext } from '../utils/auth.js';
13
- import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
13
+ import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
14
14
  import { trackOnboardingEvent } from '../utils/telemetry.js';
15
15
  import { gatewayFetch } from './case-batch.js';
16
16
  import { ONBOARDING_STAGES, ONBOARDING_VERSION, } from './onboarding-contract.js';
@@ -93,6 +93,8 @@ export function parseOnboardingStatus(value, expected) {
93
93
  invalid('its data absences');
94
94
  if (!isStrings(value.versionChoices))
95
95
  invalid('its version choices');
96
+ if (!isStrings(value.standIns) || !isStrings(value.unsetProviders))
97
+ invalid('its stand-ins');
96
98
  if (value.block !== null)
97
99
  parseOnboardingBlock(value.block);
98
100
  const checkpoint = value.checkpoint;
@@ -128,6 +130,9 @@ async function body(response, what) {
128
130
  return await response.json();
129
131
  }
130
132
  catch (error) {
133
+ // A connection that dropped mid-body is not a bad body: a wait reads again.
134
+ if (transientReadFailure(error) !== null)
135
+ throw error;
131
136
  throw new OnboardingResponseError(`The onboarding service returned ${what} that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
132
137
  }
133
138
  }
@@ -159,8 +164,11 @@ const ONBOARDING_READ_EVERY_MS = 15_000;
159
164
  const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
160
165
  /** Polls the base's onboarding until it is no longer running. No wall-time cap:
161
166
  * an interrupt stops waiting and leaves the onboarding running (`resume` is the
162
- * command that follows it again). A momentary 429/5xx (a server or runner
163
- * restarting) is read again at the next poll. */
167
+ * command that follows it again). A read that may work if asked again (a 429/5xx
168
+ * while a server or runner restarts, or no answer at all: a dropped connection
169
+ * ended a 1 h wait on 2026-10-02 with "fetch failed") is read again at the next
170
+ * poll, said once per run of failures, until the service has given no answer for
171
+ * SERVICE_SILENCE_LIMIT_MS (stall detection). */
164
172
  export async function waitForOnboarding(first, token, progress, resume) {
165
173
  const interrupted = () => {
166
174
  console.error(`\nStopped waiting. The onboarding keeps running; run \`${resume}\` again to follow it.`);
@@ -171,6 +179,8 @@ export async function waitForOnboarding(first, token, progress, resume) {
171
179
  let status = first;
172
180
  progress(`Waiting for onboarding ${status.onboardRunId ?? ''} (Ctrl-C stops waiting; the onboarding keeps running).`);
173
181
  progress(onboardingStep(status));
182
+ let failing = false;
183
+ let answeredAt = Date.now();
174
184
  while (status.state === 'onboarding') {
175
185
  await delay(ONBOARDING_READ_EVERY_MS);
176
186
  let next;
@@ -178,11 +188,18 @@ export async function waitForOnboarding(first, token, progress, resume) {
178
188
  next = await readOnboardingStatus(status.repository, status.baseCommit, token);
179
189
  }
180
190
  catch (error) {
181
- if (!(error instanceof HaystackApiError && [429, 502, 503, 504].includes(error.status)))
191
+ const why = transientReadFailure(error);
192
+ if (why === null)
182
193
  throw error;
183
- progress(`The onboarding status is unavailable for now (${error.status}); reading it again.`);
194
+ if (Date.now() - answeredAt >= SERVICE_SILENCE_LIMIT_MS)
195
+ throw new ServiceSilentError('onboarding', resume);
196
+ if (!failing)
197
+ progress(`The onboarding status is unavailable for now (${why}); reading it again.`);
198
+ failing = true;
184
199
  continue;
185
200
  }
201
+ failing = false;
202
+ answeredAt = Date.now();
186
203
  if (onboardingStep(next) !== onboardingStep(status))
187
204
  progress(onboardingStep(next));
188
205
  status = next;
@@ -273,6 +290,9 @@ export function formatOnboarding(status) {
273
290
  lines.push('', ...status.dataAbsences.map(absence => chalk.yellow(`Note: ${safe(absence)}`)));
274
291
  if (status.versionChoices.length)
275
292
  lines.push('', ...status.versionChoices.map(choice => chalk.yellow(`Note: ${safe(choice)}`)));
293
+ const outside = [...status.standIns, ...status.unsetProviders];
294
+ if (outside.length)
295
+ lines.push('', ...outside.map(note => chalk.yellow(`Note: ${safe(note)}`)));
276
296
  return lines.join('\n');
277
297
  }
278
298
  /** Telemetry: onboarding started, blocked or ready, once per state a command sees. */
@@ -351,7 +371,8 @@ export async function verifyOnboardingCommand(options) {
351
371
  const origin = resolveOriginRepository(gitRoot);
352
372
  const repository = `${origin.owner}/${origin.repository}`;
353
373
  const auth = await resolveAuthContext({ preferredLogin: options.account, owner: origin.owner, repo: origin.repository });
354
- let status = await readOnboardingStatus(repository, deriveBaseSha(gitRoot, Date.now() + EXPLICIT_WALL_MS), auth.token);
374
+ const baseCommit = deriveBaseSha(gitRoot, Date.now() + EXPLICIT_WALL_MS);
375
+ let status = await readWithRetries(() => readOnboardingStatus(repository, baseCommit, auth.token));
355
376
  reportOnboardingState(status);
356
377
  if (options.wait && status.state === 'onboarding') {
357
378
  status = await waitForOnboarding(status, auth.token, line => console.error(chalk.dim(`${clock()} ${line}`)), 'haystack verify onboarding --wait');
@@ -262,7 +262,9 @@ async function buildCompressedPatch(gitRoot, baseSha, workCommit, deadline) {
262
262
  }
263
263
  export async function derivePrecomputeRequest(gitRoot, owner, repository, deadline = Date.now() + HOOK_WALL_MS,
264
264
  /** How long the crawl may take (CRAWL-V1 amendment 8); undefined: the service's default. */
265
- budgetMs) {
265
+ budgetMs,
266
+ /** Amendment 12: 'prepare' builds and freezes only (the turn-end hook); 'crawl' is `haystack verify`. */
267
+ mode = 'crawl') {
266
268
  // Capture this before git and compression work so delivery latency cannot
267
269
  // make an older stop look newer than a later one.
268
270
  const observedAt = Date.now();
@@ -290,7 +292,7 @@ budgetMs) {
290
292
  cacheKey,
291
293
  patchGzBase64: patchGzip.toString('base64'),
292
294
  patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
293
- ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }) } }),
295
+ ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }), mode } }),
294
296
  },
295
297
  };
296
298
  }
@@ -333,7 +335,7 @@ function readWalkerStatus(value) {
333
335
  }
334
336
  throw new PrecomputeFailure('The precompute service returned an invalid walker status.');
335
337
  }
336
- const CRAWL_UNAVAILABLE_REASONS = ['runner-unavailable', 'superseded', 'enqueue-failed'];
338
+ const CRAWL_UNAVAILABLE_REASONS = ['runner-unavailable', 'enqueue-failed'];
337
339
  const isOnboardRunId = (value) => typeof value === 'string' && /^ob_[0-9a-f]{48}$/.test(value);
338
340
  function readCrawlHookStatus(value) {
339
341
  if (value && typeof value === 'object' && !Array.isArray(value)) {
@@ -365,7 +367,6 @@ function readCrawlHookStatus(value) {
365
367
  export function crawlUnavailableText(reason) {
366
368
  switch (reason) {
367
369
  case 'runner-unavailable': return 'no machine that can crawl this repository is available right now';
368
- case 'superseded': return 'a newer stop in this repository came first';
369
370
  case 'enqueue-failed': return 'the service could not queue it';
370
371
  }
371
372
  }
@@ -455,14 +456,16 @@ function printResult(result) {
455
456
  : 'Walker unavailable: the enqueue request failed.');
456
457
  else
457
458
  console.log('Walker status was not reported by the service.');
459
+ // Amendment 12: this capture builds and freezes the app only; `haystack verify` crawls it.
458
460
  if (crawl?.status === 'queued')
459
- console.log(`Crawl queued (${crawl.runId}).`);
461
+ console.log(`Building and freezing the app for this change (${crawl.runId}); run \`haystack verify\` to check it.`);
460
462
  else if (crawl?.status === 'existing')
461
- console.log(`A matching crawl already exists (${crawl.runId}).`);
463
+ console.log(`This change is already built or being built (${crawl.runId}); run \`haystack verify\` to check it.`);
462
464
  else if (crawl?.status === 'unavailable')
463
465
  console.log(`Crawl unavailable: ${crawlUnavailableText(crawl.reason)}.`);
464
466
  else if (crawl?.status === 'onboarding') {
465
- console.log(`The app is being onboarded for this base (${crawl.onboardRunId}); the crawl runs when it is ready. Run \`haystack verify\` to follow it.`);
467
+ console.log(`The app is being onboarded for this base (${crawl.onboardRunId}); this change is built and frozen when it is ready.`
468
+ + ' Run `haystack verify` to check it.');
466
469
  }
467
470
  else if (crawl?.status === 'onboarding-blocked')
468
471
  console.log(blockLines(crawl.block).join('\n'));
@@ -485,7 +488,9 @@ function configuredCrawlMinutes(gitRoot) {
485
488
  }
486
489
  return minutes;
487
490
  }
488
- export async function captureCheckout(deadline, repository,
491
+ export async function captureCheckout(deadline,
492
+ /** Amendment 12: the turn-end hook and `verify precompute` ask to prepare (build and freeze); `haystack verify` to crawl. */
493
+ mode, repository,
489
494
  /** `haystack verify --minutes`; undefined: the repository's .haystack.json crawl.minutes, else the service's default. */
490
495
  minutes) {
491
496
  const gitRoot = findGitRoot();
@@ -495,7 +500,7 @@ minutes) {
495
500
  const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
496
501
  const target = repository ?? resolveOriginRepository(gitRoot);
497
502
  const chosen = minutes ?? configuredCrawlMinutes(gitRoot);
498
- const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000);
503
+ const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000, mode);
499
504
  return { metadataDirectory, derivation };
500
505
  }
501
506
  /** The explicit (non-hook) submission of a capture: one synchronous
@@ -533,7 +538,8 @@ export async function verifyPrecomputeCommand(options) {
533
538
  const deadline = startedAt + totalWallMs;
534
539
  let outcome = 'error';
535
540
  try {
536
- const capture = await captureCheckout(deadline);
541
+ // The hook and an explicit precompute build and freeze the change ahead of `haystack verify`; they never crawl it.
542
+ const capture = await captureCheckout(deadline, 'prepare');
537
543
  if (options.hook) {
538
544
  remainingMs(deadline);
539
545
  await startPrecomputeDelivery(capture.metadataDirectory, capture.derivation.request);
@@ -26,7 +26,7 @@ import chalk from 'chalk';
26
26
  import { withSchema } from '../schema.js';
27
27
  import { findGitRoot } from '../utils/hooks.js';
28
28
  import { resolveAuthContext } from '../utils/auth.js';
29
- import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
29
+ import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
30
30
  import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
31
31
  import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_WAIT_MAX_MS, } from './crawl-contract.js';
32
32
  import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
@@ -36,8 +36,8 @@ const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
36
36
  const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
37
37
  const RUN_ID = /^cv_[0-9a-f]{48}$/;
38
38
  const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
39
- const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'incomplete', 'cancelled'];
40
- const TERMINAL = new Set(['completed', 'incomplete', 'cancelled']);
39
+ const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'prepared', 'incomplete', 'cancelled'];
40
+ const TERMINAL = new Set(['completed', 'prepared', 'incomplete', 'cancelled']);
41
41
  const STAGES = ['reconcile', 'source', 'worlds', 'blast', 'knobs', 'prepare', 'crawl', 'publish', 'cleanup'];
42
42
  const ERROR_CODES = ['source-mismatch', 'blast-unavailable', 'world-build-failed',
43
43
  'prepare-failed', 'crawl-failed', 'no-capacity', 'stalled', 'lease-lost', 'cancelled'];
@@ -50,6 +50,7 @@ const STATUS_WORDS = {
50
50
  crawling: 'exploring the app',
51
51
  draining: 'finishing the last checks',
52
52
  completed: 'finished',
53
+ prepared: 'built and ready to check',
53
54
  incomplete: 'stopped before finishing',
54
55
  cancelled: 'cancelled',
55
56
  };
@@ -213,7 +214,7 @@ export function parseCrawlView(value, expected) {
213
214
  || typeof value.changeTitle !== 'string' || !isCount(value.observedAt)
214
215
  || !isTimestamp(value.createdAt) || !isTimestamp(value.updatedAt)
215
216
  || (value.supersededBy !== null && (typeof value.supersededBy !== 'string' || !RUN_ID.test(value.supersededBy)))
216
- || typeof value.cancelRequested !== 'boolean')
217
+ || typeof value.cancelRequested !== 'boolean' || (value.mode !== 'prepare' && value.mode !== 'crawl'))
217
218
  invalid('its status');
218
219
  const clocks = value.clocks;
219
220
  if (!isRecord(clocks) || !['startedAt', 'stageStartedAt', 'crawlStartedAt', 'finishedAt']
@@ -259,6 +260,9 @@ async function responseBody(response) {
259
260
  return await response.json();
260
261
  }
261
262
  catch (error) {
263
+ // A connection that dropped mid-body is not a bad body: a wait reads again.
264
+ if (transientReadFailure(error) !== null)
265
+ throw error;
262
266
  throw new CrawlResponseError(`The crawl service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
263
267
  }
264
268
  }
@@ -333,13 +337,15 @@ function headline(view) {
333
337
  const error = results(view)?.error ?? view.error;
334
338
  return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
335
339
  }
340
+ case 'prepared':
341
+ // Amendment 12: the turn-end hook built and froze this change; `haystack verify` asks for the crawl itself.
342
+ return 'Your change is built and ready to check, but was not crawled yet. Run `haystack verify` to check it.';
336
343
  case 'cancelled':
337
- return `The crawl was cancelled${view.supersededBy ? ': a newer stop in this repository replaced it' : ''}.${found}`;
344
+ return `The crawl was cancelled.${found}`;
338
345
  default: {
339
346
  const so = bugs > 0 ? ` ${plural(bugs, 'bug')} found so far.` : '';
340
347
  if (view.cancelRequested) {
341
- return `Being cancelled${view.supersededBy ? ' (a newer stop in this repository replaced it)' : ''}: `
342
- + `${currentStep(view)}.${so}`;
348
+ return `Being cancelled: ${currentStep(view)}.${so}`;
343
349
  }
344
350
  return `Still running: ${currentStep(view)}.${so}`;
345
351
  }
@@ -482,7 +488,11 @@ function printNewBugs(view, printed) {
482
488
  }
483
489
  /** Follows the same capture until its crawl answers at its budget or ends (amendment 9). Each read is held by the
484
490
  * service until the crawl changes, so there is no pause between reads. No wall-time cap: an interrupt stops waiting
485
- * and leaves the crawl running. */
491
+ * and leaves the crawl running. A read that may work if asked again (transientReadFailure) is asked again after
492
+ * TRANSIENT_READ_PAUSE_MS, said once per run of failures: one dropped connection used to end the command. Only a
493
+ * service that has given no answer for SERVICE_SILENCE_LIMIT_MS stops the wait (stall detection). */
494
+ /** The pause before a followed crawl is read again after a read that may work if asked again. */
495
+ const TRANSIENT_READ_PAUSE_MS = 5_000;
486
496
  async function waitForCrawl(first, identity, token) {
487
497
  const interrupted = () => {
488
498
  console.error('\nStopped waiting. The crawl keeps running; run `haystack verify` again to see it.');
@@ -495,17 +505,36 @@ async function waitForCrawl(first, identity, token) {
495
505
  progress(`Waiting for crawl ${view.runId} (Ctrl-C stops waiting; the crawl keeps running).`);
496
506
  progress(currentStep(view));
497
507
  printNewBugs(view, printed);
508
+ let failing = false;
509
+ let answeredAt = Date.now();
498
510
  while (!TERMINAL.has(reportedStatus(view))) {
499
- // The followed crawl's own read is held, so only a change to it wakes the wait (Astra review: a capture read could sit up to
500
- // 25 s on another title's crawl of the same code).
501
- let next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
502
- // A newer stop of the same change supersedes this crawl: once it is superseded or being cancelled, the capture's newest crawl
503
- // is read at once (not held) and followed if it is this same change.
504
- if (next.supersededBy !== null || next.cancelRequested) {
505
- const newest = await readCrawl(identity, token);
506
- if (newest !== null && sameChange(newest, identity) && newest.runId !== view.runId)
507
- next = newest;
511
+ let next;
512
+ try {
513
+ // The followed crawl's own read is held, so only a change to it wakes the wait (Astra review: a capture read could sit up
514
+ // to 25 s on another title's crawl of the same code).
515
+ next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
516
+ // Once this crawl is being cancelled, the capture's newest crawl is read at once (not held) and followed if it is this
517
+ // same change (amendment 12: no crawl replaces another, but one can be cancelled and asked for again).
518
+ if (next.cancelRequested) {
519
+ const newest = await readCrawl(identity, token);
520
+ if (newest !== null && sameChange(newest, identity) && newest.runId !== view.runId)
521
+ next = newest;
522
+ }
508
523
  }
524
+ catch (error) {
525
+ const why = transientReadFailure(error);
526
+ if (why === null)
527
+ throw error;
528
+ if (Date.now() - answeredAt >= SERVICE_SILENCE_LIMIT_MS)
529
+ throw new ServiceSilentError('crawl', 'haystack verify');
530
+ if (!failing)
531
+ progress(`The crawl's status is unavailable for now (${why}); reading it again.`);
532
+ failing = true;
533
+ await new Promise(resolve => setTimeout(resolve, TRANSIENT_READ_PAUSE_MS));
534
+ continue;
535
+ }
536
+ failing = false;
537
+ answeredAt = Date.now();
509
538
  if (next.runId !== view.runId)
510
539
  progress(`A newer crawl of this same change took over (${next.runId}).`);
511
540
  if (!TERMINAL.has(reportedStatus(next)) && (next.runId !== view.runId || currentStep(next) !== currentStep(view))) {
@@ -530,7 +559,10 @@ function staleCrawl(found, identity) {
530
559
  return 'other-time';
531
560
  if (found.status === 'cancelled' || found.status === 'incomplete')
532
561
  return found.status;
533
- return !TERMINAL.has(found.status) && found.cancelRequested ? 'cancelling' : null;
562
+ if (!TERMINAL.has(found.status) && found.cancelRequested)
563
+ return 'cancelling';
564
+ // A `prepared` run is asked for its crawl whatever its mode (a cancel can leave a widened one prepared).
565
+ return found.mode === 'prepare' || found.status === 'prepared' ? 'prepare-only' : null;
534
566
  }
535
567
  function staleWords(found, stale) {
536
568
  switch (stale) {
@@ -539,6 +571,9 @@ function staleWords(found, stale) {
539
571
  case 'cancelled': return `This change's last crawl was cancelled (${found.runId})`;
540
572
  case 'incomplete': return `This change's last crawl stopped before finishing (${found.runId})`;
541
573
  case 'cancelling': return `This change's crawl is being cancelled (${found.runId})`;
574
+ case 'prepare-only': return found.status === 'prepared'
575
+ ? `Your change was built and frozen ahead of time (${found.runId})`
576
+ : `Your change is being built and frozen ahead of time (${found.runId})`;
542
577
  }
543
578
  }
544
579
  /** Submits the capture through the explicit precompute submission when no crawl
@@ -546,21 +581,9 @@ function staleWords(found, stale) {
546
581
  * when no crawl could be started), whether the base is being onboarded or its
547
582
  * onboarding is blocked, and the line to report. */
548
583
  async function submitCapture(capture, token, found) {
549
- let acknowledgment;
550
- // A momentary 429/5xx (a server or runner restarting) is asked again, as the stop hook's sender does: admission is
551
- // idempotent for one capture, so a repeat can only find what the first created.
552
- const pauses = [2_000, 5_000, 10_000];
553
- for (let attempt = 0; acknowledgment === undefined; attempt += 1) {
554
- try {
555
- acknowledgment = await postPrecomputeCapture(capture, token, Date.now() + EXPLICIT_WALL_MS);
556
- }
557
- catch (error) {
558
- const transient = error instanceof HaystackApiError && [429, 502, 503, 504].includes(error.status);
559
- if (!transient || attempt >= pauses.length)
560
- throw normalModeFailure(error);
561
- await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
562
- }
563
- }
584
+ // A momentary 429/5xx or a dropped connection (a server or runner restarting) is asked again, as the stop hook's
585
+ // sender does: admission is idempotent for one capture, so a repeat can only find what the first created.
586
+ const acknowledgment = await readWithRetries(() => postPrecomputeCapture(capture, token, Date.now() + EXPLICIT_WALL_MS)).catch((error) => { throw normalModeFailure(error); });
564
587
  const crawl = acknowledgment.crawl;
565
588
  const before = found === null ? 'No crawl existed for this change' : staleWords(found.view, found.stale);
566
589
  if (crawl === undefined) {
@@ -584,7 +607,8 @@ async function submitCapture(capture, token, found) {
584
607
  : `The service already had a crawl for this change (${crawl.runId}).` };
585
608
  }
586
609
  if (!anotherCrawl(found.stale) && crawl.runId === found.view.runId) {
587
- return { runId: crawl.runId, onboarding: null, line: `${before}; asked the service to run it again.` };
610
+ return { runId: crawl.runId, onboarding: null, line: found.stale === 'prepare-only'
611
+ ? `${before}; checking it now, reusing what was built.` : `${before}; asked the service to run it again.` };
588
612
  }
589
613
  return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
590
614
  ? `${before}; started one for this change (${crawl.runId}).`
@@ -619,7 +643,7 @@ export async function verifyCommand(options) {
619
643
  console.error(line);
620
644
  else
621
645
  console.log(line); };
622
- const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo), minutesOption(options.minutes));
646
+ const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'crawl', repositoryOverride(options.repo), minutesOption(options.minutes));
623
647
  const request = capture.derivation.request;
624
648
  const change = checkedChange(request.baseCommit, request.workCommit);
625
649
  if (!request.crawl) {
@@ -641,7 +665,8 @@ export async function verifyCommand(options) {
641
665
  budgetMs: request.crawl.budgetMs ?? null,
642
666
  };
643
667
  const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
644
- let view = await readCrawl(identity, auth.token);
668
+ // One-shot reads ask again after a failure that may work if asked again, as the waits do.
669
+ let view = await readWithRetries(() => readCrawl(identity, auth.token));
645
670
  let onboarding = null;
646
671
  const stale = view === null ? null : staleCrawl(view, identity);
647
672
  if (view === null || stale !== null) {
@@ -649,7 +674,7 @@ export async function verifyCommand(options) {
649
674
  let submitted = await submitCapture(capture, auth.token, found);
650
675
  note(submitted.line);
651
676
  if (submitted.onboarding !== null) {
652
- onboarding = await readOnboardingStatus(identity.repository, identity.baseCommit, auth.token);
677
+ onboarding = await readWithRetries(() => readOnboardingStatus(identity.repository, identity.baseCommit, auth.token));
653
678
  reportOnboardingState(onboarding);
654
679
  if (options.wait !== false && onboarding.state === 'onboarding') {
655
680
  onboarding = await waitForOnboarding(onboarding, auth.token, progress, 'haystack verify');
@@ -671,7 +696,8 @@ export async function verifyCommand(options) {
671
696
  process.exitCode = 1;
672
697
  return;
673
698
  }
674
- view = await readCrawlRun(submitted.runId, identity, auth.token);
699
+ const runId = submitted.runId;
700
+ view = await readWithRetries(() => readCrawlRun(runId, identity, auth.token));
675
701
  if (found !== null && !anotherCrawl(found.stale) && view.runId === found.view.runId && TERMINAL.has(view.status)) {
676
702
  note('The service did not run it again; its last result follows.');
677
703
  }
@@ -683,7 +709,8 @@ export async function verifyCommand(options) {
683
709
  process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding, crawl: view }), null, 2)}\n`);
684
710
  else {
685
711
  console.log(formatCrawl(view));
686
- const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? [])];
712
+ const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? []), ...(onboarding?.standIns ?? []),
713
+ ...(onboarding?.unsetProviders ?? [])];
687
714
  if (notes.length)
688
715
  console.log(['', ...notes.map(note => `Note: ${safe(note)}`)].join('\n'));
689
716
  }
package/dist/index.js CHANGED
@@ -153,7 +153,8 @@ repository already keeps working. init shows each change as a diff, then makes
153
153
  it with --yes (or a yes at the prompt):
154
154
  • Claude Code's Stop hook (\`haystack verify precompute --hook\`) in the
155
155
  per-developer .claude/settings.local.json, kept out of commits, when
156
- Claude Code is in use here
156
+ Claude Code is in use here: it builds and freezes the changed app ahead
157
+ of time, so \`haystack verify\` starts from there
157
158
  • a short note in AGENTS.md (and CLAUDE.md when the repository keeps one)
158
159
  telling coding agents to run \`haystack verify\` after a change
159
160
  Then it starts onboarding the app, so it is usually ready by the first
@@ -264,10 +265,9 @@ Exit codes:
264
265
  0 the crawl answered or finished (the bugs it found are in the output), or
265
266
  it is still running and --no-wait was given
266
267
  1 the command failed, or no crawl could be started for this change
267
- 2 the crawl ended without finishing: it stopped early or was cancelled
268
- (a newer stop in the repository replaces an older crawl), or the
269
- machines it used could not be proven shut down; or onboarding stopped
270
- before finishing
268
+ 2 the crawl ended without finishing: it stopped early or was cancelled,
269
+ or the machines it used could not be proven shut down; or onboarding
270
+ stopped before finishing
271
271
  3 onboarding is blocked: the output says what would unblock it
272
272
 
273
273
  The hosted fleet run of pushed commits is \`haystack verify hosted start\`.
@@ -367,7 +367,7 @@ Examples:
367
367
  });
368
368
  verify
369
369
  .command('precompute')
370
- .description('Precompute reusable verification inputs for the current working tree')
370
+ .description('Build and freeze the current working tree\'s app ahead of `haystack verify` (no crawl)')
371
371
  .option('--hook', 'Run silently as a best-effort repository hook')
372
372
  .action(async (options) => {
373
373
  await runPublicCommand(async () => {
@@ -1359,7 +1359,8 @@ hooks
1359
1359
  Configures your coding CLI to run \`haystack triage --hook\` on session start.
1360
1360
  This shows pending PR analysis results when you open a new terminal session.
1361
1361
  Claude Code also runs \`haystack verify precompute --hook\` when a session stops,
1362
- which starts a crawl of the change in the background (see \`haystack verify\`).
1362
+ which builds and freezes the changed app in the background so that
1363
+ \`haystack verify\` starts from there. It never crawls; \`haystack verify\` does.
1363
1364
 
1364
1365
  Claude Code: Native SessionStart and Stop hooks (.claude/settings.local.json;
1365
1366
  --shared writes the committed .claude/settings.json)
@@ -25,6 +25,70 @@ export class HaystackApiError extends Error {
25
25
  return this.status === 401 || this.status === 403;
26
26
  }
27
27
  }
28
+ /** The network failures fetch (undici) reports as its TypeError's `cause.code`: the connection refused, reset, dropped,
29
+ * unreachable or timed out, or the name not resolved for now. A request it could not even build (ERR_INVALID_URL) is not one. */
30
+ const TRANSPORT_FAILURE_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'EPIPE', 'ETIMEDOUT', 'ENETUNREACH',
31
+ 'ENETDOWN', 'EHOSTUNREACH', 'EHOSTDOWN', 'EAI_AGAIN', 'ENOTFOUND', 'UND_ERR_SOCKET', 'UND_ERR_CLOSED',
32
+ 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT']);
33
+ /**
34
+ * Why a read failed in a way that asking again may fix, or null when it did not: the service or a runner restarting
35
+ * (429, 502, 503, 504), no answer at all (fetch's TypeError whose cause is one of TRANSPORT_FAILURE_CODES), or a request
36
+ * past its own deadline (TimeoutError). A wait loop reads again on these; anything else (sign-in, a bad request, a request
37
+ * that could not be built, a bug) is the caller's to report.
38
+ */
39
+ export function transientReadFailure(error) {
40
+ if (error instanceof HaystackApiError)
41
+ return [429, 502, 503, 504].includes(error.status) ? `HTTP ${error.status}` : null;
42
+ if (error instanceof Error && error.name === 'TimeoutError')
43
+ return 'no answer in time';
44
+ return error instanceof TypeError ? transportCause(error.cause, 0) : null;
45
+ }
46
+ /** The transport failure somewhere in a fetch TypeError's cause chain. A connection dropped while the body is read fails as
47
+ * TypeError "terminated", whose cause may be the socket error or the request's TimeoutError (Bugbot, #3760); several
48
+ * addresses tried at once (IPv4 and IPv6) fail as one AggregateError. */
49
+ function transportCause(cause, depth) {
50
+ if (!(cause instanceof Error) || depth > 4)
51
+ return null;
52
+ if (cause.name === 'TimeoutError')
53
+ return 'no answer in time';
54
+ const code = cause.code;
55
+ if (typeof code === 'string' && TRANSPORT_FAILURE_CODES.has(code))
56
+ return `no answer: ${code}`;
57
+ if (cause instanceof AggregateError) {
58
+ for (const inner of cause.errors) {
59
+ const found = transportCause(inner, depth + 1);
60
+ if (found !== null)
61
+ return found;
62
+ }
63
+ }
64
+ return transportCause(cause.cause, depth + 1);
65
+ }
66
+ /** How long a wait keeps asking while the service gives no answer, counted from its last answer. Stall detection, not a
67
+ * cap on the work: a service that answers is followed for as long as the work runs. */
68
+ export const SERVICE_SILENCE_LIMIT_MS = 10 * 60_000;
69
+ /** A wait stopped because the service gave no answer for SERVICE_SILENCE_LIMIT_MS (a dead network, a wrong
70
+ * HAYSTACK_API_BASE, a long outage); the work it was following keeps running on the service. */
71
+ export class ServiceSilentError extends Error {
72
+ constructor(work, resume) {
73
+ super(`Haystack has not answered for ${SERVICE_SILENCE_LIMIT_MS / 60_000} minutes; the ${work} keeps running on the service. `
74
+ + `Run \`${resume}\` again to follow it.`);
75
+ this.name = 'ServiceSilentError';
76
+ }
77
+ }
78
+ /** A one-shot read, asked again after a failure that may work if asked again (transientReadFailure), at most three more times. */
79
+ export async function readWithRetries(read) {
80
+ const pauses = [2_000, 5_000, 10_000];
81
+ for (let attempt = 0;; attempt += 1) {
82
+ try {
83
+ return await read();
84
+ }
85
+ catch (error) {
86
+ if (transientReadFailure(error) === null || attempt >= pauses.length)
87
+ throw error;
88
+ await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
89
+ }
90
+ }
91
+ }
28
92
  function headers(token, extra) {
29
93
  const result = new Headers(extra);
30
94
  result.set('Authorization', `Bearer ${token}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "haystack verify: run your app with and without a change, and see what the change broke",
5
5
  "type": "module",
6
6
  "bin": {