@mrclrchtr/supi-code-intelligence 6.2.0 → 6.4.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.
Files changed (68) hide show
  1. package/README.md +2 -0
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-lsp/README.md +2 -0
  5. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  6. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  7. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/README.md +52 -0
  8. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/index.js +785 -129
  9. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/legacy.js +660 -98
  10. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/package.json +10 -4
  11. package/node_modules/@mrclrchtr/supi-lsp/package.json +3 -3
  12. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +16 -2
  13. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/workspace-sentinels.ts +4 -32
  14. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/workspace-sources.ts +108 -0
  15. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-process-crash-report.ts +106 -0
  16. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +4 -4
  17. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +513 -113
  18. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostics.ts +43 -15
  19. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +40 -33
  20. package/node_modules/@mrclrchtr/supi-lsp/src/session/workspace-lsp-runtime.ts +36 -6
  21. package/node_modules/@mrclrchtr/supi-lsp/src/summary.ts +1 -1
  22. package/node_modules/@mrclrchtr/supi-lsp/src/workspace-path-policy.ts +18 -9
  23. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  24. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  25. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/README.md +29 -1
  26. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.cjs +99 -19
  27. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.cjs.map +4 -4
  28. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.js +94 -18
  29. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.js.map +4 -4
  30. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm +0 -0
  31. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm.map +14 -14
  32. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/package.json +13 -8
  33. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.cjs +97 -19
  34. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.cjs.map +4 -4
  35. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.d.cts +28 -8
  36. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.d.cts.map +1 -1
  37. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.d.ts +28 -8
  38. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.d.ts.map +1 -1
  39. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.js +92 -18
  40. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.js.map +4 -4
  41. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.wasm +0 -0
  42. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.wasm.map +17 -17
  43. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +7 -4
  44. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/kotlin/tree-sitter-kotlin.wasm +0 -0
  45. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/kotlin/tree-sitter-kotlin.wasm.json +2 -2
  46. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/sql/tree-sitter-sql.wasm +0 -0
  47. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/sql/tree-sitter-sql.wasm.json +2 -2
  48. package/package.json +5 -5
  49. package/src/session/health-refresh.ts +19 -12
  50. package/src/session/health-types.ts +8 -3
  51. package/src/session/health-workflow.ts +7 -3
  52. package/src/session/session.ts +12 -5
  53. package/src/substrate/lsp/maintenance.ts +53 -47
  54. package/src/substrate/lsp/settings.ts +2 -1
  55. package/src/substrate/lsp/source-tracking.ts +247 -0
  56. package/src/tool/code_find/guidance.ts +3 -3
  57. package/src/tool/code_graph/guidance.ts +2 -2
  58. package/src/tool/code_health/guidance.ts +3 -3
  59. package/src/tool/code_health/markdown.ts +21 -5
  60. package/src/tool/code_health/refresh-outcome.ts +48 -7
  61. package/src/tool/code_health/refresh-status.ts +83 -16
  62. package/src/tool/code_health/tui.ts +4 -0
  63. package/src/tool/code_inspect/guidance.ts +5 -3
  64. package/src/tool/code_orientation/guidance.ts +3 -4
  65. package/src/tool/code_refactor_apply/guidance.ts +1 -1
  66. package/src/tool/code_refactor_plan/guidance.ts +3 -5
  67. package/src/tool/code_resolve/guidance.ts +3 -3
  68. package/src/tool/guidance.ts +11 -9
@@ -26,6 +26,7 @@ import {
26
26
  truncateDebugIdentity as truncateIdentity,
27
27
  } from "@mrclrchtr/supi-core/debug";
28
28
  import { resolveToolPath as resolveSessionPath, uriToFile } from "@mrclrchtr/supi-core/path";
29
+ import { isMissingFileError } from "../client/client-file-state.ts";
29
30
  import { getServerForFile } from "../config/config.ts";
30
31
  import {
31
32
  getFileScopeDecision,
@@ -63,14 +64,23 @@ import {
63
64
  type WorkspaceSentinelScanOptions,
64
65
  type WorkspaceSentinelSyncResult,
65
66
  } from "../diagnostics/workspace-sentinels.ts";
67
+ import {
68
+ scanWorkspaceSources as scanAutomaticWorkspaceSources,
69
+ type WorkspaceSourceInventory,
70
+ } from "../diagnostics/workspace-sources.ts";
66
71
  import { raceRequestControl } from "../session/readiness.ts";
67
72
  import {
68
- emptyProcessCrashRecoverySummary,
69
- type ProcessCrashRecoverySummary,
73
+ emptyProcessCrashRecoveryReport,
74
+ type ProcessCrashRecoveryReport,
70
75
  type ScopeDecisionEntry,
71
76
  type ScopeDecisionSummary,
72
77
  type WorkspaceDiagnosticReport,
73
78
  } from "../session/runtime-diagnostics.ts";
79
+ import {
80
+ type BulkTrackFileOutcome,
81
+ type BulkTrackFilesResult,
82
+ MAX_BULK_TRACK_FILES,
83
+ } from "../session/workspace-lsp-runtime.ts";
74
84
  import {
75
85
  displayRelativeFilePath,
76
86
  formatCoverageSummaryText,
@@ -94,6 +104,11 @@ import {
94
104
  syncClientFileAndGetDiagnostics,
95
105
  } from "./manager-diagnostics.ts";
96
106
  import { clientKey, rememberKnownRoot, resolveRootForFile } from "./manager-helpers.ts";
107
+ import {
108
+ boundProcessCrashFailureMessage,
109
+ buildProcessCrashRecoveryReport,
110
+ type ProcessCrashRecoveryRouteResult,
111
+ } from "./manager-process-crash-report.ts";
97
112
  import { buildProjectServerInfo } from "./manager-project-info.ts";
98
113
  import type {
99
114
  ActiveCoverageSummaryEntry,
@@ -119,6 +134,7 @@ type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
119
134
 
120
135
  /** Maximum tracked-file entries retained in one scope-decision telemetry summary. */
121
136
  const SCOPE_DECISION_MAX_ENTRIES = 24;
137
+ const MAX_CONCURRENT_BULK_TRACKS = 4;
122
138
 
123
139
  interface ScopeDecisionAccumulator {
124
140
  entries: ScopeDecisionEntry[];
@@ -180,7 +196,7 @@ type FileClientRequestOptions = {
180
196
 
181
197
  interface FileReadinessResult {
182
198
  client: LspClient | null;
183
- processCrashRecovery: ProcessCrashRecoverySummary;
199
+ processCrashRecovery: ProcessCrashRecoveryReport;
184
200
  }
185
201
 
186
202
  interface ProcessCrashRecoveryState {
@@ -196,40 +212,56 @@ interface ProcessCrashRecoveryState {
196
212
  pending: Promise<LspClient | null> | null;
197
213
  /** Start time for the current replacement attempt, when one exists. */
198
214
  attemptStartedAt?: number;
215
+ /** Bounded caught Error.message from a failed replacement startup. */
216
+ failureMessage?: string;
199
217
  }
200
218
 
201
- type ProcessCrashRecoveryOutcome = "attempt" | "success" | "failure" | "exhausted" | "cancelled";
202
-
203
219
  type ProcessCrashDemandResult = {
204
- /** Whether at least one required crashed route supports the operation. */
220
+ /** Whether at least one crashed route supports the operation. */
205
221
  hasSupport: boolean;
222
+ /** Whether replacement work changed the route set and needs a fresh pass. */
223
+ requiresFreshEvidence: boolean;
206
224
  /** Separate route-level result for this explicit process-crash demand. */
207
- processCrashRecovery: ProcessCrashRecoverySummary;
225
+ processCrashRecovery: ProcessCrashRecoveryReport;
208
226
  /** Required routes that remain unavailable after shared recovery settles. */
209
227
  failures: string[];
210
228
  /** In-scope tracked files from required routes that remain unavailable. */
211
229
  failedFiles: string[];
212
230
  };
213
231
 
232
+ type ProcessCrashRecoverySnapshot = {
233
+ readonly key: string;
234
+ /** Mutable state handle used only to await the shared replacement. */
235
+ readonly recovery: ProcessCrashRecoveryState;
236
+ readonly failedClient: LspClient;
237
+ readonly name: string;
238
+ readonly root: string;
239
+ readonly statusReason: NonNullable<ProcessCrashRecoveryState["statusReason"]>;
240
+ readonly files: readonly string[];
241
+ };
242
+
214
243
  type RequiredProcessCrashDemand = {
215
- key: string;
216
- recovery: ProcessCrashRecoveryState;
244
+ snapshot: ProcessCrashRecoverySnapshot;
217
245
  failedFiles: readonly string[];
218
246
  };
219
247
 
220
- type ProcessCrashRecoveryRouteOutcome = "pending" | "recovered" | "failed";
248
+ type ImmediateProcessCrashDemand = {
249
+ route: ProcessCrashRecoveryRouteResult;
250
+ failedFiles: readonly string[];
251
+ reportOnly: boolean;
252
+ };
221
253
 
222
- function summarizeProcessCrashRecoveryRoutes(
223
- outcomes: Iterable<ProcessCrashRecoveryRouteOutcome>,
224
- ): ProcessCrashRecoverySummary {
225
- const summary = emptyProcessCrashRecoverySummary();
226
- for (const outcome of outcomes) {
227
- summary.attemptedRoutes++;
228
- if (outcome === "recovered") summary.recoveredRoutes++;
229
- if (outcome === "failed") summary.failedRoutes++;
230
- }
231
- return summary;
232
- }
254
+ type ProcessCrashDemandSelection = {
255
+ immediate: ImmediateProcessCrashDemand[];
256
+ required: RequiredProcessCrashDemand[];
257
+ };
258
+
259
+ type ProcessCrashRecoveryTelemetryOutcome =
260
+ | "attempt"
261
+ | "success"
262
+ | "failure"
263
+ | "exhausted"
264
+ | "cancelled";
233
265
 
234
266
  // ── LspManager ────────────────────────────────────────────────────────
235
267
  export class LspManager {
@@ -265,6 +297,9 @@ export class LspManager {
265
297
  private handledCrashGenerations = new Map<string, number>();
266
298
  /** Prevent late startup and lifecycle callbacks from republishing after shutdown. */
267
299
  private shuttingDown = false;
300
+ /** Coalesce per-file tracking transitions into one bulk transition. */
301
+ private trackedFilesBatchDepth = 0;
302
+ private trackedFilesBatchChanged = false;
268
303
  constructor(
269
304
  private readonly config: LspConfig,
270
305
  private readonly cwd: string,
@@ -292,20 +327,24 @@ export class LspManager {
292
327
  }
293
328
 
294
329
  /**
295
- * Return the consumed process-crash outcome for a file readiness result.
296
- * The result supports unavailable and non-resolved waits; it is null before
297
- * an attempt is consumed and after successful recovery, so old success is
298
- * not reported as a new outcome. This is a passive read and never starts
299
- * recovery.
330
+ * Return an exhausted process-crash outcome for a file readiness result.
331
+ * This is a passive read and never starts recovery. A crashed or pending
332
+ * route has no final result until an explicit operation observes it.
300
333
  */
301
- getProcessCrashRecoverySummaryForFile(filePath: string): ProcessCrashRecoverySummary | null {
334
+ getProcessCrashRecoveryReportForFile(filePath: string): ProcessCrashRecoveryReport | null {
302
335
  const route = this.resolveFileRoute(resolveSessionPath(this.cwd, filePath));
303
336
  const recovery = route ? this.processCrashRecoveries.get(route.key) : undefined;
304
- if (!recovery?.attemptConsumed || recovery.statusReason === undefined) return null;
305
-
306
- const outcome: ProcessCrashRecoveryRouteOutcome =
307
- recovery.statusReason === "process-crash-recovery-exhausted" ? "failed" : "pending";
308
- return summarizeProcessCrashRecoveryRoutes([outcome]);
337
+ if (!route || recovery?.statusReason !== "process-crash-recovery-exhausted") return null;
338
+ return buildProcessCrashRecoveryReport(
339
+ [
340
+ {
341
+ name: route.serverName,
342
+ root: route.root,
343
+ outcome: "recovery-exhausted",
344
+ },
345
+ ],
346
+ this.cwd,
347
+ );
309
348
  }
310
349
 
311
350
  /**
@@ -318,7 +357,7 @@ export class LspManager {
318
357
  return this.canServeFile(filePath);
319
358
  }
320
359
 
321
- /** Inventory automatic workspace sentinel and source paths with the runtime policy. */
360
+ /** Inventory automatic workspace sentinels with the runtime policy. */
322
361
  scanWorkspaceSentinels(options: WorkspaceSentinelScanOptions = {}): Map<string, number> {
323
362
  return scanAutomaticWorkspaceSentinels(this.cwd, {
324
363
  ...options,
@@ -326,7 +365,16 @@ export class LspManager {
326
365
  });
327
366
  }
328
367
 
329
- /** Refresh the automatic workspace inventory with the runtime policy. */
368
+ /** Scan configured LSP source extensions with the runtime path policy. */
369
+ scanWorkspaceSources(control?: CodeRequestControl): Promise<WorkspaceSourceInventory> {
370
+ return scanAutomaticWorkspaceSources(this.cwd, {
371
+ fileTypes: Object.values(this.config.servers).flatMap((server) => server.fileTypes),
372
+ policy: this.automaticPathPolicy,
373
+ control,
374
+ });
375
+ }
376
+
377
+ /** Refresh the automatic workspace sentinel with the runtime policy. */
330
378
  syncWorkspaceSentinelSnapshot(
331
379
  previous: Map<string, number>,
332
380
  options: WorkspaceSentinelScanOptions = {},
@@ -392,9 +440,14 @@ export class LspManager {
392
440
  // A recovered route keeps its consumed budget but has no active reason.
393
441
  // Passive callers can use its running client normally.
394
442
 
395
- return this.startServerForRoot(route.serverName, route.root);
443
+ return this.startServerForRoot(route.serverName, route.root, options.control);
396
444
  }
397
- async startServerForRoot(serverName: string, root: string): Promise<LspClient | null> {
445
+ async startServerForRoot(
446
+ serverName: string,
447
+ root: string,
448
+ control?: CodeRequestControl,
449
+ ): Promise<LspClient | null> {
450
+ throwIfCodeRequestInterrupted(control);
398
451
  if (this.shuttingDown) return null;
399
452
  const serverConfig = this.config.servers[serverName];
400
453
  if (!serverConfig) return null;
@@ -425,17 +478,18 @@ export class LspManager {
425
478
  // This prevents spawning duplicate server processes when two
426
479
  // callers race through getClientForFile before either await yields.
427
480
  const pending = this.pendingStarts.get(key);
428
- if (pending) return pending;
481
+ if (pending) return raceRequestControl(pending, control);
429
482
 
430
483
  const startPromise = this.performStart(serverName, serverConfig, root, key);
431
484
  this.pendingStarts.set(key, startPromise);
432
- try {
433
- return await startPromise;
434
- } finally {
435
- if (this.pendingStarts.get(key) === startPromise) {
436
- this.pendingStarts.delete(key);
437
- }
438
- }
485
+ // Keep the shared startup registered until it settles. A cancelled
486
+ // caller only stops waiting; it must not allow a duplicate route start.
487
+ void startPromise
488
+ .finally(() => {
489
+ if (this.pendingStarts.get(key) === startPromise) this.pendingStarts.delete(key);
490
+ })
491
+ .catch(() => {});
492
+ return raceRequestControl(startPromise, control);
439
493
  }
440
494
 
441
495
  private createClient(
@@ -480,6 +534,11 @@ export class LspManager {
480
534
  }
481
535
  }
482
536
 
537
+ if (kind === "tracked-files" && this.trackedFilesBatchDepth > 0) {
538
+ this.trackedFilesBatchChanged = true;
539
+ return;
540
+ }
541
+
483
542
  const aggregateKind =
484
543
  kind === "readiness" && generation > 1 && client.ready ? "recovery" : kind;
485
544
  this.publishLifecycle(aggregateKind);
@@ -509,6 +568,7 @@ export class LspManager {
509
568
  failedClient: client,
510
569
  files: this.trackedFilesForClient(client),
511
570
  pending: null,
571
+ failureMessage: undefined,
512
572
  });
513
573
  }
514
574
 
@@ -547,66 +607,192 @@ export class LspManager {
547
607
  /**
548
608
  * Recover all crashed routes selected by one evidence-producing operation.
549
609
  * Each route keeps its independent attempt budget and shared replacement.
610
+ * Selection uses one immutable route-fact snapshot so scope and retention
611
+ * facts cannot change halfway through the demand.
550
612
  */
551
613
  private async recoverProcessCrashDemand(
552
- selectFiles: (recovery: ProcessCrashRecoveryState) => readonly string[] | null,
614
+ selectFiles: (snapshot: ProcessCrashRecoverySnapshot) => readonly string[] | null,
553
615
  control?: CodeRequestControl,
616
+ options: { skipWhenNoRetainedFile?: boolean } = {},
554
617
  ): Promise<ProcessCrashDemandResult> {
555
- const required = Array.from(this.processCrashRecoveries.entries()).flatMap(
556
- ([key, recovery]): RequiredProcessCrashDemand[] => {
557
- if (recovery.statusReason === undefined) return [];
558
- const failedFiles = selectFiles(recovery);
559
- return failedFiles === null ? [] : [{ key, recovery, failedFiles }];
560
- },
618
+ const { immediate, required } = this.selectProcessCrashDemandRoutes(
619
+ this.snapshotProcessCrashRoutes(),
620
+ selectFiles,
621
+ options,
561
622
  );
623
+ const hasSupport = immediate.length > 0 || required.length > 0;
562
624
  if (required.length === 0) {
625
+ const failures = immediate
626
+ .filter((entry) => !entry.reportOnly)
627
+ .map((entry) => this.formatProcessCrashDemandFailure(entry.route, entry.route.outcome));
563
628
  return {
564
- hasSupport: false,
565
- processCrashRecovery: emptyProcessCrashRecoverySummary(),
566
- failures: [],
567
- failedFiles: [],
629
+ hasSupport,
630
+ requiresFreshEvidence: false,
631
+ processCrashRecovery: buildProcessCrashRecoveryReport(
632
+ immediate.map(({ route }) => route),
633
+ this.cwd,
634
+ ),
635
+ failures,
636
+ failedFiles: Array.from(
637
+ new Set(
638
+ immediate
639
+ .filter((entry) => !entry.reportOnly)
640
+ .flatMap(({ failedFiles }) => failedFiles),
641
+ ),
642
+ ),
568
643
  };
569
644
  }
570
645
 
571
646
  await Promise.all(
572
- required.map(async ({ key, recovery }) => {
573
- const serverConfig = this.config.servers[recovery.failedClient.name];
647
+ required.map(async ({ snapshot }) => {
648
+ const serverConfig = this.config.servers[snapshot.name];
574
649
  if (!serverConfig) return;
575
650
  await this.acquireProcessCrashReplacement(
576
- recovery,
651
+ snapshot.recovery,
577
652
  {
578
- serverName: recovery.failedClient.name,
653
+ serverName: snapshot.name,
579
654
  serverConfig,
580
- root: recovery.failedClient.root,
581
- key,
655
+ root: snapshot.root,
656
+ key: snapshot.key,
582
657
  },
583
658
  control,
584
659
  );
585
660
  }),
586
661
  );
587
662
 
588
- const failedDemands = required.filter(({ recovery }) => recovery.statusReason !== undefined);
589
- const outcomes = required.map(
590
- ({ recovery }): ProcessCrashRecoveryRouteOutcome =>
591
- recovery.statusReason === undefined ? "recovered" : "failed",
663
+ const observedRequired = required.filter(
664
+ ({ snapshot }) => !this.isProcessCrashDemandCancelled(snapshot),
665
+ );
666
+ const failedDemands = observedRequired.filter(
667
+ ({ snapshot }) => snapshot.recovery.statusReason !== undefined,
668
+ );
669
+ const recoveredRoutes = observedRequired.map(
670
+ ({ snapshot }): ProcessCrashRecoveryRouteResult => ({
671
+ name: snapshot.name,
672
+ root: snapshot.root,
673
+ outcome: snapshot.recovery.statusReason === undefined ? "recovered" : "recovery-failed",
674
+ ...(snapshot.recovery.statusReason === undefined || !snapshot.recovery.failureMessage
675
+ ? {}
676
+ : { failureMessage: snapshot.recovery.failureMessage }),
677
+ }),
592
678
  );
679
+ const failedImmediate = immediate.filter((entry) => !entry.reportOnly);
593
680
  return {
594
- hasSupport: true,
595
- processCrashRecovery: summarizeProcessCrashRecoveryRoutes(outcomes),
596
- failures: failedDemands.map(({ recovery }) => this.formatProcessCrashDemandFailure(recovery)),
597
- failedFiles: Array.from(new Set(failedDemands.flatMap(({ failedFiles }) => failedFiles))),
681
+ hasSupport,
682
+ requiresFreshEvidence: observedRequired.length > 0,
683
+ processCrashRecovery: buildProcessCrashRecoveryReport(
684
+ [...immediate.map(({ route }) => route), ...recoveredRoutes],
685
+ this.cwd,
686
+ ),
687
+ failures: [
688
+ ...failedImmediate.map((entry) =>
689
+ this.formatProcessCrashDemandFailure(entry.route, entry.route.outcome),
690
+ ),
691
+ ...failedDemands.map(({ snapshot }) =>
692
+ this.formatProcessCrashDemandFailure(snapshot.recovery, "recovery-failed"),
693
+ ),
694
+ ],
695
+ failedFiles: Array.from(
696
+ new Set([
697
+ ...failedImmediate.flatMap(({ failedFiles }) => failedFiles),
698
+ ...failedDemands.flatMap(({ failedFiles }) => failedFiles),
699
+ ]),
700
+ ),
598
701
  };
599
702
  }
600
703
 
601
- private formatProcessCrashDemandFailure(recovery: ProcessCrashRecoveryState): string {
602
- const root = (path.relative(this.cwd, recovery.failedClient.root) || ".").replaceAll("\\", "/");
704
+ private isProcessCrashDemandCancelled(snapshot: ProcessCrashRecoverySnapshot): boolean {
705
+ return (
706
+ snapshot.recovery.statusReason !== undefined &&
707
+ (this.shuttingDown || this.processCrashRecoveries.get(snapshot.key) !== snapshot.recovery)
708
+ );
709
+ }
710
+
711
+ private selectProcessCrashDemandRoutes(
712
+ snapshots: readonly ProcessCrashRecoverySnapshot[],
713
+ selectFiles: (snapshot: ProcessCrashRecoverySnapshot) => readonly string[] | null,
714
+ options: { skipWhenNoRetainedFile?: boolean },
715
+ ): ProcessCrashDemandSelection {
716
+ const immediate: ImmediateProcessCrashDemand[] = [];
717
+ const required: RequiredProcessCrashDemand[] = [];
718
+
719
+ for (const snapshot of snapshots) {
720
+ const failedFiles = selectFiles(snapshot);
721
+ if (failedFiles === null) continue;
722
+
723
+ const exhausted = snapshot.statusReason === "process-crash-recovery-exhausted";
724
+ const noRetainedFile = failedFiles.length === 0;
725
+ if (exhausted) {
726
+ immediate.push({
727
+ route: {
728
+ name: snapshot.name,
729
+ root: snapshot.root,
730
+ outcome: "recovery-exhausted",
731
+ },
732
+ failedFiles,
733
+ reportOnly: options.skipWhenNoRetainedFile === true && noRetainedFile,
734
+ });
735
+ continue;
736
+ }
737
+ if (
738
+ options.skipWhenNoRetainedFile === true &&
739
+ noRetainedFile &&
740
+ snapshot.statusReason === "process-crashed"
741
+ ) {
742
+ immediate.push({
743
+ route: {
744
+ name: snapshot.name,
745
+ root: snapshot.root,
746
+ outcome: "skipped-no-retained-file",
747
+ },
748
+ failedFiles,
749
+ reportOnly: true,
750
+ });
751
+ continue;
752
+ }
753
+ required.push({ snapshot, failedFiles });
754
+ }
755
+ return { immediate, required };
756
+ }
757
+
758
+ private snapshotProcessCrashRoutes(): ProcessCrashRecoverySnapshot[] {
759
+ return Array.from(this.processCrashRecoveries.entries()).flatMap(([key, recovery]) => {
760
+ if (recovery.statusReason === undefined) return [];
761
+ return [
762
+ {
763
+ key,
764
+ recovery,
765
+ failedClient: recovery.failedClient,
766
+ name: recovery.failedClient.name,
767
+ root: recovery.failedClient.root,
768
+ statusReason: recovery.statusReason,
769
+ files: [...recovery.files],
770
+ },
771
+ ];
772
+ });
773
+ }
774
+
775
+ private formatProcessCrashDemandFailure(
776
+ recovery: ProcessCrashRecoveryState | ProcessCrashRecoveryRouteResult,
777
+ outcome?: ProcessCrashRecoveryRouteResult["outcome"],
778
+ ): string {
779
+ const isRouteResult = "outcome" in recovery;
780
+ const name = isRouteResult ? recovery.name : recovery.failedClient.name;
781
+ const root = isRouteResult
782
+ ? recovery.root
783
+ : (path.relative(this.cwd, recovery.failedClient.root) || ".").replaceAll("\\", "/");
784
+ const routeOutcome =
785
+ outcome ??
786
+ (isRouteResult
787
+ ? recovery.outcome
788
+ : recovery.statusReason === "process-crash-recovery-exhausted"
789
+ ? "recovery-exhausted"
790
+ : "recovery-failed");
603
791
  const reason =
604
- recovery.statusReason === "process-crash-recovery-exhausted"
792
+ routeOutcome === "recovery-exhausted"
605
793
  ? "process recovery exhausted; reload required"
606
- : recovery.statusReason === "process-crash-recovery-pending"
607
- ? "process recovery in progress"
608
- : "process crashed";
609
- return `${recovery.failedClient.name} @ ${root} is unavailable — ${reason}.`;
794
+ : "process recovery failed";
795
+ return `${name} @ ${root} is unavailable — ${reason}.`;
610
796
  }
611
797
 
612
798
  private startProcessCrashRecovery(
@@ -670,6 +856,7 @@ export class LspManager {
670
856
 
671
857
  this.unavailable.delete(key);
672
858
  recovery.statusReason = undefined;
859
+ recovery.failureMessage = undefined;
673
860
  this.publishLifecycle("recovery");
674
861
  this.restoreTrackedDocuments(started, recovery.files);
675
862
  this.recordProcessCrashRecoveryEvent(
@@ -716,7 +903,7 @@ export class LspManager {
716
903
  // LspClient's initialize request already has the 30-second transport
717
904
  // bound. Do not apply the 5-second diagnostic-restart bound here.
718
905
  await replacement.start();
719
- } catch {
906
+ } catch (error) {
720
907
  if (this.isProcessCrashRecoveryCancelled(recovery, route.key)) {
721
908
  await replacement.forceKill().catch(() => {});
722
909
  this.recordProcessCrashRecoveryEvent(
@@ -728,7 +915,7 @@ export class LspManager {
728
915
  return null;
729
916
  }
730
917
  if (recovery.statusReason !== "process-crash-recovery-exhausted") {
731
- this.markProcessCrashRecoveryFailed(recovery, route.key, replacement);
918
+ this.markProcessCrashRecoveryFailed(recovery, route.key, replacement, error);
732
919
  this.recordProcessCrashRecoveryEvent(
733
920
  route.serverName,
734
921
  route.root,
@@ -776,8 +963,13 @@ export class LspManager {
776
963
  recovery: ProcessCrashRecoveryState,
777
964
  key: string,
778
965
  replacement: LspClient,
966
+ error: unknown,
779
967
  ): void {
780
968
  recovery.statusReason = "process-crash-recovery-exhausted";
969
+ recovery.failureMessage =
970
+ error instanceof Error && error.message
971
+ ? boundProcessCrashFailureMessage(error.message)
972
+ : undefined;
781
973
  this.unavailable.set(key, "start-failed");
782
974
  if (this.clients.get(key) === replacement) this.clients.delete(key);
783
975
  // A failed initialize may still leave a child process alive. The
@@ -789,7 +981,7 @@ export class LspManager {
789
981
  private recordProcessCrashRecoveryEvent(
790
982
  serverName: string,
791
983
  root: string,
792
- outcome: ProcessCrashRecoveryOutcome,
984
+ outcome: ProcessCrashRecoveryTelemetryOutcome,
793
985
  startedAt?: number,
794
986
  ): void {
795
987
  try {
@@ -1065,33 +1257,103 @@ export class LspManager {
1065
1257
  async waitUntilFileReady(
1066
1258
  filePath: string,
1067
1259
  control?: CodeRequestControl,
1068
- onProcessCrashRecovery?: (summary: ProcessCrashRecoverySummary) => void,
1260
+ onProcessCrashRecovery?: (report: ProcessCrashRecoveryReport) => void,
1069
1261
  ): Promise<FileReadinessResult> {
1070
1262
  throwIfCodeRequestInterrupted(control);
1071
1263
  const resolvedPath = resolveSessionPath(this.cwd, filePath);
1072
1264
  const route = this.resolveFileRoute(resolvedPath);
1073
1265
  const recovery = route ? this.processCrashRecoveries.get(route.key) : undefined;
1074
- const processCrashRecoveryRequested =
1075
- recovery !== undefined && (recovery.pending !== null || recovery.statusReason !== undefined);
1076
- if (processCrashRecoveryRequested) {
1077
- onProcessCrashRecovery?.(summarizeProcessCrashRecoveryRoutes(["pending"]));
1078
- }
1266
+ const statusAtStart = recovery?.statusReason;
1267
+ const processCrashRecoveryRequested = statusAtStart !== undefined;
1079
1268
  const client = await this.getClientForFile(resolvedPath, {
1080
1269
  recoverProcessCrash: true,
1081
1270
  control,
1082
1271
  });
1083
- const processCrashRecovery = processCrashRecoveryRequested
1084
- ? summarizeProcessCrashRecoveryRoutes([client ? "recovered" : "failed"])
1085
- : emptyProcessCrashRecoverySummary();
1272
+ const processCrashRecovery = this.buildFileProcessCrashRecoveryReport(
1273
+ route,
1274
+ recovery,
1275
+ statusAtStart,
1276
+ client,
1277
+ );
1278
+ if (processCrashRecoveryRequested) onProcessCrashRecovery?.(processCrashRecovery);
1086
1279
  if (!client) return { client: null, processCrashRecovery };
1087
- // The caller's wait stops on cancellation even though the shared
1088
- // readiness state keeps its own lifecycle.
1089
- await client.getReady(control);
1090
- await this.warmSemanticProject(client, resolvedPath, true, control);
1091
- throwIfCodeRequestInterrupted(control);
1280
+ try {
1281
+ await client.getReady(control);
1282
+ await this.warmSemanticProject(client, resolvedPath, true, control);
1283
+ throwIfCodeRequestInterrupted(control);
1284
+ } catch (error) {
1285
+ const failedRecovery = this.buildFileProcessCrashFailureReport(
1286
+ route,
1287
+ recovery,
1288
+ statusAtStart,
1289
+ error,
1290
+ );
1291
+ if (failedRecovery) {
1292
+ onProcessCrashRecovery?.(failedRecovery);
1293
+ return { client: null, processCrashRecovery: failedRecovery };
1294
+ }
1295
+ throw error;
1296
+ }
1092
1297
  return { client, processCrashRecovery };
1093
1298
  }
1094
1299
 
1300
+ private buildFileProcessCrashRecoveryReport(
1301
+ route: FileRoute | null,
1302
+ recovery: ProcessCrashRecoveryState | undefined,
1303
+ statusAtStart: ProcessCrashRecoveryState["statusReason"],
1304
+ client: LspClient | null,
1305
+ ): ProcessCrashRecoveryReport {
1306
+ if (statusAtStart === undefined) return emptyProcessCrashRecoveryReport();
1307
+ return buildProcessCrashRecoveryReport(
1308
+ [
1309
+ {
1310
+ name: route?.serverName ?? "unknown",
1311
+ root: route?.root ?? this.cwd,
1312
+ outcome:
1313
+ statusAtStart === "process-crash-recovery-exhausted"
1314
+ ? "recovery-exhausted"
1315
+ : client
1316
+ ? "recovered"
1317
+ : "recovery-failed",
1318
+ ...(client || !recovery?.failureMessage
1319
+ ? {}
1320
+ : { failureMessage: recovery.failureMessage }),
1321
+ },
1322
+ ],
1323
+ this.cwd,
1324
+ );
1325
+ }
1326
+
1327
+ private buildFileProcessCrashFailureReport(
1328
+ route: FileRoute | null,
1329
+ recovery: ProcessCrashRecoveryState | undefined,
1330
+ statusAtStart: ProcessCrashRecoveryState["statusReason"],
1331
+ error: unknown,
1332
+ ): ProcessCrashRecoveryReport | null {
1333
+ if (
1334
+ statusAtStart === undefined ||
1335
+ statusAtStart === "process-crash-recovery-exhausted" ||
1336
+ recovery?.statusReason !== "process-crash-recovery-exhausted"
1337
+ ) {
1338
+ return null;
1339
+ }
1340
+ return buildProcessCrashRecoveryReport(
1341
+ [
1342
+ {
1343
+ name: route?.serverName ?? "unknown",
1344
+ root: route?.root ?? this.cwd,
1345
+ outcome: "recovery-failed",
1346
+ ...(error instanceof Error && error.message
1347
+ ? { failureMessage: boundProcessCrashFailureMessage(error.message) }
1348
+ : recovery.failureMessage
1349
+ ? { failureMessage: recovery.failureMessage }
1350
+ : {}),
1351
+ },
1352
+ ],
1353
+ this.cwd,
1354
+ );
1355
+ }
1356
+
1095
1357
  /**
1096
1358
  * Wait until one started client is query-ready, then warm its project.
1097
1359
  * Returns the number of concrete clients that are ready after the warm-up.
@@ -1159,6 +1421,118 @@ export class LspManager {
1159
1421
  return unavailableCodeQuery(`Diagnostic collection failed for ${resolvedPath}: ${detail}`);
1160
1422
  }
1161
1423
  }
1424
+ /**
1425
+ * Track a bounded set of automatic source files with shared route starts.
1426
+ * Inputs after the first 256 unique paths are ignored.
1427
+ */
1428
+ async bulkTrackFiles(
1429
+ filePaths: readonly string[],
1430
+ control?: CodeRequestControl,
1431
+ ): Promise<BulkTrackFilesResult> {
1432
+ throwIfCodeRequestInterrupted(control);
1433
+ const selected = Array.from(
1434
+ new Set(filePaths.map((filePath) => resolveSessionPath(this.cwd, filePath))),
1435
+ ).slice(0, MAX_BULK_TRACK_FILES);
1436
+ if (selected.length === 0) return { outcomes: [] };
1437
+
1438
+ this.trackedFilesBatchDepth++;
1439
+ const outcomes = new Array<BulkTrackFileOutcome>(selected.length);
1440
+ const workerCount = Math.min(MAX_CONCURRENT_BULK_TRACKS, selected.length);
1441
+ let nextIndex = 0;
1442
+ const worker = async (): Promise<void> => {
1443
+ for (;;) {
1444
+ throwIfCodeRequestInterrupted(control);
1445
+ const index = nextIndex++;
1446
+ if (index >= selected.length) return;
1447
+ outcomes[index] = await this.trackBulkFile(selected[index], control);
1448
+ }
1449
+ };
1450
+ const workers = Array.from({ length: workerCount }, () => worker());
1451
+ try {
1452
+ await Promise.all(workers);
1453
+ return { outcomes };
1454
+ } catch (error) {
1455
+ // Let every worker observe cancellation before the batch transition is
1456
+ // published. Shared route startup may continue, but no worker may open
1457
+ // another document after this caller stops waiting.
1458
+ await Promise.allSettled(workers);
1459
+ throw error;
1460
+ } finally {
1461
+ this.trackedFilesBatchDepth--;
1462
+ if (this.trackedFilesBatchDepth === 0 && this.trackedFilesBatchChanged) {
1463
+ this.trackedFilesBatchChanged = false;
1464
+ this.publishLifecycle("tracked-files");
1465
+ }
1466
+ }
1467
+ }
1468
+
1469
+ private async trackBulkFile(
1470
+ filePath: string,
1471
+ control?: CodeRequestControl,
1472
+ ): Promise<BulkTrackFileOutcome> {
1473
+ const resolvedPath = resolveSessionPath(this.cwd, filePath);
1474
+ if (!this.isConfiguredAutomaticSourceFile(resolvedPath)) {
1475
+ return { file: resolvedPath, kind: "unsupported", reason: "not-automatic-source" };
1476
+ }
1477
+ const fileStatus = this.inspectSourceFile(resolvedPath);
1478
+ if (fileStatus !== "regular") {
1479
+ if (fileStatus === "missing") {
1480
+ return { file: resolvedPath, kind: "unsupported", reason: "missing" };
1481
+ }
1482
+ if (fileStatus === "not-automatic-source") {
1483
+ return { file: resolvedPath, kind: "unsupported", reason: "not-automatic-source" };
1484
+ }
1485
+ return {
1486
+ file: resolvedPath,
1487
+ kind: "unavailable",
1488
+ reason: "The source file could not be inspected.",
1489
+ };
1490
+ }
1491
+ if (this.isFileTracked(resolvedPath)) {
1492
+ return { file: resolvedPath, kind: "already-tracked" };
1493
+ }
1494
+
1495
+ throwIfCodeRequestInterrupted(control);
1496
+ // Recheck the automatic source-support rule after the inventory scan. A
1497
+ // config, exclusion, or route can change while a refresh is in flight.
1498
+ if (!this.isSupportedSourceFile(resolvedPath)) {
1499
+ return {
1500
+ file: resolvedPath,
1501
+ kind: "unavailable",
1502
+ reason: "No available LSP route can serve this source file.",
1503
+ };
1504
+ }
1505
+
1506
+ const client = await this.ensureFileOpen(resolvedPath, { control });
1507
+ if (!client) {
1508
+ return {
1509
+ file: resolvedPath,
1510
+ kind: "unavailable",
1511
+ reason: "The LSP route could not open this source file.",
1512
+ };
1513
+ }
1514
+ return { file: resolvedPath, kind: "tracked" };
1515
+ }
1516
+
1517
+ private isConfiguredAutomaticSourceFile(filePath: string): boolean {
1518
+ return this.isAutomaticScopePath(filePath) && getServerForFile(this.config, filePath) !== null;
1519
+ }
1520
+
1521
+ private inspectSourceFile(
1522
+ filePath: string,
1523
+ ): "regular" | "missing" | "not-automatic-source" | "unavailable" {
1524
+ try {
1525
+ return fs.statSync(filePath).isFile() ? "regular" : "not-automatic-source";
1526
+ } catch (error) {
1527
+ if (isMissingFileError(error)) return "missing";
1528
+ return "unavailable";
1529
+ }
1530
+ }
1531
+
1532
+ private isFileTracked(filePath: string): boolean {
1533
+ return this.getExistingClientForFile(filePath)?.openFiles.includes(filePath) ?? false;
1534
+ }
1535
+
1162
1536
  /** Close a file across any active LSP clients and clear its cached diagnostics. */
1163
1537
  closeFile(filePath: string): void {
1164
1538
  closeFileAcrossClients(this.clients.values(), resolveSessionPath(this.cwd, filePath));
@@ -1245,18 +1619,26 @@ export class LspManager {
1245
1619
  processCrashDemand?: { scopes?: readonly string[] };
1246
1620
  control?: CodeRequestControl;
1247
1621
  }): Promise<WorkspaceRecoveryResult> {
1248
- const demand = options?.processCrashDemand
1249
- ? await this.recoverProcessCrashDemand((recovery) => {
1250
- const files = this.processCrashDiagnosticFiles(recovery, options.processCrashDemand);
1251
- return files.length > 0 ? files : null;
1252
- }, options.control)
1622
+ const processCrashDemand = options?.processCrashDemand;
1623
+ const demand = processCrashDemand
1624
+ ? await this.recoverProcessCrashDemand(
1625
+ (snapshot) => {
1626
+ if (!this.processCrashDiagnosticRouteInScope(snapshot, processCrashDemand)) {
1627
+ return null;
1628
+ }
1629
+ return this.processCrashDiagnosticFiles(snapshot, processCrashDemand);
1630
+ },
1631
+ options?.control,
1632
+ { skipWhenNoRetainedFile: true },
1633
+ )
1253
1634
  : {
1254
1635
  hasSupport: false,
1255
- processCrashRecovery: emptyProcessCrashRecoverySummary(),
1636
+ requiresFreshEvidence: false,
1637
+ processCrashRecovery: emptyProcessCrashRecoveryReport(),
1256
1638
  failures: [],
1257
1639
  failedFiles: [],
1258
1640
  };
1259
- const recoveryOptions = demand.hasSupport
1641
+ const recoveryOptions = demand.requiresFreshEvidence
1260
1642
  ? { ...options, initialEvidence: undefined }
1261
1643
  : options;
1262
1644
  const result = await recoverWorkspaceDiagnosticsImpl(this, recoveryOptions);
@@ -1266,21 +1648,34 @@ export class LspManager {
1266
1648
  );
1267
1649
  }
1268
1650
 
1651
+ private processCrashDiagnosticRouteInScope(
1652
+ snapshot: ProcessCrashRecoverySnapshot,
1653
+ demand: { scopes?: readonly string[] },
1654
+ ): boolean {
1655
+ return this.processCrashDiagnosticScopes(demand).some(
1656
+ (scope) =>
1657
+ projectRoots.isWithinOrEqual(scope, snapshot.root) ||
1658
+ projectRoots.isWithinOrEqual(snapshot.root, scope),
1659
+ );
1660
+ }
1661
+
1269
1662
  private processCrashDiagnosticFiles(
1270
- recovery: ProcessCrashRecoveryState,
1271
- demand: { scopes?: readonly string[] } | undefined,
1663
+ snapshot: ProcessCrashRecoverySnapshot,
1664
+ demand: { scopes?: readonly string[] },
1272
1665
  ): string[] {
1273
- if (!demand) return [];
1274
- const scopes = demand.scopes?.map((scope) => resolveSessionPath(this.cwd, scope));
1275
- return recovery.files.filter(
1666
+ const scopes = this.processCrashDiagnosticScopes(demand);
1667
+ return snapshot.files.filter(
1276
1668
  (file) =>
1277
1669
  this.isDiagnosticFile(file) &&
1278
- (!scopes ||
1279
- scopes.length === 0 ||
1280
- scopes.some((scope) => projectRoots.isWithinOrEqual(scope, file))),
1670
+ scopes.some((scope) => projectRoots.isWithinOrEqual(scope, file)),
1281
1671
  );
1282
1672
  }
1283
1673
 
1674
+ private processCrashDiagnosticScopes(demand: { scopes?: readonly string[] }): string[] {
1675
+ const scopes = demand.scopes?.map((scope) => resolveSessionPath(this.cwd, scope));
1676
+ return scopes && scopes.length > 0 ? scopes : [this.cwd];
1677
+ }
1678
+
1284
1679
  private addProcessCrashDiagnosticEvidence(
1285
1680
  result: WorkspaceRecoveryResult,
1286
1681
  demand: ProcessCrashDemandResult,
@@ -1665,9 +2060,9 @@ export class LspManager {
1665
2060
  );
1666
2061
  }
1667
2062
  const demand = await this.recoverProcessCrashDemand(
1668
- (recovery) =>
1669
- recovery.failedClient.serverCapabilities?.workspaceSymbolProvider &&
1670
- routeMatches(recovery.failedClient)
2063
+ (snapshot) =>
2064
+ snapshot.failedClient.serverCapabilities?.workspaceSymbolProvider &&
2065
+ routeMatches(snapshot.failedClient)
1671
2066
  ? []
1672
2067
  : null,
1673
2068
  control,
@@ -1745,9 +2140,14 @@ export class LspManager {
1745
2140
  const client = await this.getClientForFile(resolvedPath, options);
1746
2141
  if (!client) return null;
1747
2142
  try {
1748
- client.didOpen(resolvedPath, fs.readFileSync(resolvedPath, "utf-8"));
2143
+ const content = fs.readFileSync(resolvedPath, "utf-8");
2144
+ // Route startup can be shared. The document open belongs to this
2145
+ // caller, so check again after the shared wait before sending didOpen.
2146
+ throwIfCodeRequestInterrupted(options.control);
2147
+ client.didOpen(resolvedPath, content);
1749
2148
  return client;
1750
- } catch {
2149
+ } catch (error) {
2150
+ if (isCodeRequestInterruption(error, options.control)) throw error;
1751
2151
  this.closeFile(resolvedPath);
1752
2152
  return null;
1753
2153
  }