@mrclrchtr/supi-code-intelligence 6.3.0 → 7.0.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 (136) hide show
  1. package/README.md +164 -82
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +8 -1
  4. package/node_modules/@mrclrchtr/supi-core/README.md +8 -0
  5. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  6. package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +10 -3
  7. package/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/llm.ts +141 -16
  9. package/node_modules/@mrclrchtr/supi-lsp/README.md +35 -13
  10. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  11. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +8 -1
  12. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/README.md +8 -0
  13. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  14. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +10 -3
  15. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
  16. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/llm.ts +141 -16
  17. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/index.js +3 -1
  18. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/legacy.js +3 -1
  19. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/package.json +1 -1
  20. package/node_modules/@mrclrchtr/supi-lsp/package.json +3 -3
  21. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +16 -0
  22. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-cache.ts +43 -35
  23. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-capabilities.ts +83 -17
  24. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-collection.ts +108 -92
  25. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-evidence.ts +44 -82
  26. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-host.ts +3 -4
  27. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-publication.ts +9 -68
  28. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-pull.ts +45 -58
  29. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-refresh.ts +230 -331
  30. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-request.ts +201 -1
  31. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-timing.ts +129 -70
  32. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-typescript.ts +395 -0
  33. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-waiters.ts +8 -12
  34. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostics.ts +685 -183
  35. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-state.ts +2 -2
  36. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-sync.ts +60 -60
  37. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-request-enrollment.ts +75 -0
  38. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-semantic-input-barrier.ts +443 -0
  39. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-semantic-input-errors.ts +30 -0
  40. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-semantic-input-file.ts +15 -0
  41. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +290 -37
  42. package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +72 -16
  43. package/node_modules/@mrclrchtr/supi-lsp/src/config/capabilities.ts +1 -0
  44. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-scope.ts +30 -4
  45. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/evidence.ts +1 -1
  46. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/workspace-sentinels.ts +4 -32
  47. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/workspace-sources.ts +108 -0
  48. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +10 -4
  49. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-process-crash-report.ts +50 -1
  50. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +11 -7
  51. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +770 -56
  52. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostic-surface.ts +3 -1
  53. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostics.ts +37 -4
  54. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +48 -12
  55. package/node_modules/@mrclrchtr/supi-lsp/src/session/workspace-lsp-runtime.ts +50 -4
  56. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  57. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +8 -1
  58. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/README.md +8 -0
  59. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  60. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +10 -3
  61. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
  62. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/llm.ts +141 -16
  63. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +3 -3
  64. package/node_modules/@mrclrchtr/supi-tree-sitter/src/provider/tree-sitter-provider.ts +2 -0
  65. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/callee-display-name.ts +165 -0
  66. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/callees.ts +67 -9
  67. package/node_modules/@mrclrchtr/supi-tree-sitter/src/types.ts +2 -0
  68. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  69. package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  70. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  71. package/node_modules/yaml/dist/nodes/Alias.js +25 -23
  72. package/node_modules/yaml/package.json +1 -1
  73. package/package.json +5 -5
  74. package/src/analysis/health/diagnostics.ts +1 -2
  75. package/src/analysis/health/file-scope.ts +4 -1
  76. package/src/analysis/health/server-status.ts +1 -1
  77. package/src/analysis/relations/callees.ts +17 -8
  78. package/src/analysis/relations/implementations.ts +1 -2
  79. package/src/analysis/relations/references.ts +8 -4
  80. package/src/analysis/relations/types.ts +6 -30
  81. package/src/analysis/target/anchored.ts +9 -1
  82. package/src/analysis/target/symbol.ts +29 -11
  83. package/src/analysis/target/types.ts +14 -6
  84. package/src/api.ts +2 -0
  85. package/src/session/graph/collect.ts +18 -3
  86. package/src/session/health-refresh.ts +27 -11
  87. package/src/session/health-types.ts +21 -4
  88. package/src/session/health-workflow.ts +28 -22
  89. package/src/session/orientation-types.ts +5 -6
  90. package/src/session/orientation-workflow.ts +10 -2
  91. package/src/session/session.ts +12 -5
  92. package/src/session/target-workflow.ts +9 -6
  93. package/src/substrate/lsp/maintenance.ts +53 -47
  94. package/src/substrate/lsp/source-tracking.ts +252 -0
  95. package/src/tool/code_find/guidance.ts +3 -3
  96. package/src/tool/code_find/spec.ts +5 -4
  97. package/src/tool/code_find/tui.ts +7 -1
  98. package/src/tool/code_graph/details.ts +32 -0
  99. package/src/tool/code_graph/display.ts +54 -0
  100. package/src/tool/code_graph/format.ts +60 -0
  101. package/src/tool/code_graph/guidance.ts +2 -2
  102. package/src/tool/code_graph/markdown.ts +76 -198
  103. package/src/tool/code_graph/read-next.ts +65 -0
  104. package/src/tool/code_graph/result.ts +118 -83
  105. package/src/tool/code_graph/tui.ts +216 -54
  106. package/src/tool/code_health/guidance.ts +3 -5
  107. package/src/tool/code_health/markdown.ts +90 -57
  108. package/src/tool/code_health/refresh-outcome.ts +51 -0
  109. package/src/tool/code_health/refresh-status.ts +130 -43
  110. package/src/tool/code_health/scope.ts +78 -0
  111. package/src/tool/code_health/spec.ts +1 -1
  112. package/src/tool/code_health/tui.ts +29 -38
  113. package/src/tool/code_inspect/guidance.ts +5 -3
  114. package/src/tool/code_inspect/tui.ts +7 -1
  115. package/src/tool/code_orientation/guidance.ts +3 -4
  116. package/src/tool/code_orientation/result.ts +31 -8
  117. package/src/tool/code_orientation/tui.ts +26 -5
  118. package/src/tool/code_refactor_apply/guidance.ts +1 -1
  119. package/src/tool/code_refactor_apply/tui.ts +7 -3
  120. package/src/tool/code_refactor_plan/guidance.ts +3 -5
  121. package/src/tool/code_refactor_plan/tui.ts +7 -1
  122. package/src/tool/code_resolve/guidance.ts +3 -5
  123. package/src/tool/code_resolve/result.ts +13 -10
  124. package/src/tool/code_resolve/tui.ts +18 -1
  125. package/src/tool/guidance.ts +11 -9
  126. package/src/tool/infra/truncate.ts +39 -13
  127. package/src/tool/result/candidate-row.ts +27 -0
  128. package/src/tool/result/errors.ts +21 -6
  129. package/src/tool/result/types.ts +7 -3
  130. package/src/tool/schemas.ts +5 -0
  131. package/src/types/execution.ts +2 -0
  132. package/src/types/index.ts +2 -0
  133. package/src/ui/tui/candidate-legacy-body.ts +69 -0
  134. package/src/ui/tui/candidate-selection.ts +366 -0
  135. package/src/ui/tui/common.ts +26 -31
  136. package/src/ui/tui/execution-error.ts +92 -0
@@ -26,12 +26,14 @@ 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,
32
33
  invalidateTsconfigCacheForConfig,
33
34
  invalidateTsconfigCacheForConfigDir,
34
35
  isProjectConfigFileName,
36
+ isTsconfigApplicableFile,
35
37
  } from "../config/tsconfig-scope.ts";
36
38
  import type {
37
39
  DetectedProjectServer,
@@ -63,14 +65,26 @@ import {
63
65
  type WorkspaceSentinelScanOptions,
64
66
  type WorkspaceSentinelSyncResult,
65
67
  } from "../diagnostics/workspace-sentinels.ts";
68
+ import {
69
+ scanWorkspaceSources as scanAutomaticWorkspaceSources,
70
+ type WorkspaceSourceInventory,
71
+ } from "../diagnostics/workspace-sources.ts";
66
72
  import { raceRequestControl } from "../session/readiness.ts";
67
73
  import {
68
74
  emptyProcessCrashRecoveryReport,
75
+ emptyStartupRetryReport,
69
76
  type ProcessCrashRecoveryReport,
70
77
  type ScopeDecisionEntry,
71
78
  type ScopeDecisionSummary,
79
+ type StartupRetryReport,
72
80
  type WorkspaceDiagnosticReport,
73
81
  } from "../session/runtime-diagnostics.ts";
82
+ import { scanProjectCapabilities } from "../session/scanner.ts";
83
+ import {
84
+ type BulkTrackFileOutcome,
85
+ type BulkTrackFilesResult,
86
+ MAX_BULK_TRACK_FILES,
87
+ } from "../session/workspace-lsp-runtime.ts";
74
88
  import {
75
89
  displayRelativeFilePath,
76
90
  formatCoverageSummaryText,
@@ -97,7 +111,9 @@ import { clientKey, rememberKnownRoot, resolveRootForFile } from "./manager-help
97
111
  import {
98
112
  boundProcessCrashFailureMessage,
99
113
  buildProcessCrashRecoveryReport,
114
+ buildStartupRetryReport,
100
115
  type ProcessCrashRecoveryRouteResult,
116
+ type StartupRetryRouteResult,
101
117
  } from "./manager-process-crash-report.ts";
102
118
  import { buildProjectServerInfo } from "./manager-project-info.ts";
103
119
  import type {
@@ -124,6 +140,7 @@ type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
124
140
 
125
141
  /** Maximum tracked-file entries retained in one scope-decision telemetry summary. */
126
142
  const SCOPE_DECISION_MAX_ENTRIES = 24;
143
+ const MAX_CONCURRENT_BULK_TRACKS = 4;
127
144
 
128
145
  interface ScopeDecisionAccumulator {
129
146
  entries: ScopeDecisionEntry[];
@@ -186,6 +203,45 @@ type FileClientRequestOptions = {
186
203
  interface FileReadinessResult {
187
204
  client: LspClient | null;
188
205
  processCrashRecovery: ProcessCrashRecoveryReport;
206
+ startupRetry: StartupRetryReport;
207
+ }
208
+
209
+ interface FileReadinessCallbacks {
210
+ readonly onProcessCrashRecovery?: (report: ProcessCrashRecoveryReport) => void;
211
+ readonly onStartupRetry?: (report: StartupRetryReport) => void;
212
+ }
213
+
214
+ interface FailedStartState {
215
+ readonly serverName: string;
216
+ readonly root: string;
217
+ readonly failureMessage?: string;
218
+ }
219
+
220
+ interface ExplicitRouteCandidate {
221
+ readonly route: FileRoute;
222
+ readonly processCrashRecovery?: ProcessCrashRecoveryState;
223
+ readonly startupFailure?: FailedStartState;
224
+ }
225
+
226
+ interface ExplicitRouteAttemptResult {
227
+ readonly candidate: ExplicitRouteCandidate;
228
+ readonly kind: "startup" | "discovery" | "process-crash" | "none";
229
+ readonly client: LspClient | null;
230
+ readonly attempted: boolean;
231
+ readonly failureMessage?: string;
232
+ }
233
+
234
+ interface ExplicitRouteAttemptLease {
235
+ readonly promise: Promise<ExplicitRouteAttemptResult>;
236
+ users: number;
237
+ settled: boolean;
238
+ }
239
+
240
+ interface ExplicitRouteClassification {
241
+ startupResult?: StartupRetryRouteResult;
242
+ processResult?: ProcessCrashRecoveryRouteResult;
243
+ failure?: string;
244
+ failedFiles: string[];
189
245
  }
190
246
 
191
247
  interface ProcessCrashRecoveryState {
@@ -212,6 +268,8 @@ type ProcessCrashDemandResult = {
212
268
  requiresFreshEvidence: boolean;
213
269
  /** Separate route-level result for this explicit process-crash demand. */
214
270
  processCrashRecovery: ProcessCrashRecoveryReport;
271
+ /** Separate result for explicit initial-start retries. */
272
+ startupRetry: StartupRetryReport;
215
273
  /** Required routes that remain unavailable after shared recovery settles. */
216
274
  failures: string[];
217
275
  /** In-scope tracked files from required routes that remain unavailable. */
@@ -258,6 +316,10 @@ export class LspManager {
258
316
  private clients = new Map<string, LspClient>();
259
317
  /** Per-root startup failures keyed by "serverName:root" */
260
318
  private unavailable = new Map<string, UnavailableReason>();
319
+ /** Initial-start failures that an explicit health refresh may retry. */
320
+ private failedStarts = new Map<string, FailedStartState>();
321
+ /** Shared explicit route attempts for concurrent health refresh calls. */
322
+ private explicitRouteAttempts = new Map<string, ExplicitRouteAttemptLease>();
261
323
  /** Memoized per-command availability of LSP server binaries on PATH */
262
324
  private commandAvailability = new Map<string, boolean>();
263
325
  /** Guards against concurrent client creation for the same server:root key. */
@@ -286,6 +348,9 @@ export class LspManager {
286
348
  private handledCrashGenerations = new Map<string, number>();
287
349
  /** Prevent late startup and lifecycle callbacks from republishing after shutdown. */
288
350
  private shuttingDown = false;
351
+ /** Coalesce per-file tracking transitions into one bulk transition. */
352
+ private trackedFilesBatchDepth = 0;
353
+ private trackedFilesBatchChanged = false;
289
354
  constructor(
290
355
  private readonly config: LspConfig,
291
356
  private readonly cwd: string,
@@ -343,7 +408,7 @@ export class LspManager {
343
408
  return this.canServeFile(filePath);
344
409
  }
345
410
 
346
- /** Inventory automatic workspace sentinel and source paths with the runtime policy. */
411
+ /** Inventory automatic workspace sentinels with the runtime policy. */
347
412
  scanWorkspaceSentinels(options: WorkspaceSentinelScanOptions = {}): Map<string, number> {
348
413
  return scanAutomaticWorkspaceSentinels(this.cwd, {
349
414
  ...options,
@@ -351,7 +416,16 @@ export class LspManager {
351
416
  });
352
417
  }
353
418
 
354
- /** Refresh the automatic workspace inventory with the runtime policy. */
419
+ /** Scan configured LSP source extensions with the runtime path policy. */
420
+ scanWorkspaceSources(control?: CodeRequestControl): Promise<WorkspaceSourceInventory> {
421
+ return scanAutomaticWorkspaceSources(this.cwd, {
422
+ fileTypes: Object.values(this.config.servers).flatMap((server) => server.fileTypes),
423
+ policy: this.automaticPathPolicy,
424
+ control,
425
+ });
426
+ }
427
+
428
+ /** Refresh the automatic workspace sentinel with the runtime policy. */
355
429
  syncWorkspaceSentinelSnapshot(
356
430
  previous: Map<string, number>,
357
431
  options: WorkspaceSentinelScanOptions = {},
@@ -417,14 +491,22 @@ export class LspManager {
417
491
  // A recovered route keeps its consumed budget but has no active reason.
418
492
  // Passive callers can use its running client normally.
419
493
 
420
- return this.startServerForRoot(route.serverName, route.root);
494
+ return this.startServerForRoot(route.serverName, route.root, options.control);
421
495
  }
422
- async startServerForRoot(serverName: string, root: string): Promise<LspClient | null> {
496
+ async startServerForRoot(
497
+ serverName: string,
498
+ root: string,
499
+ control?: CodeRequestControl,
500
+ options: { retryFailedStart?: boolean } = {},
501
+ ): Promise<LspClient | null> {
502
+ throwIfCodeRequestInterrupted(control);
423
503
  if (this.shuttingDown) return null;
424
504
  const serverConfig = this.config.servers[serverName];
425
505
  if (!serverConfig) return null;
426
506
  const key = clientKey(serverName, root);
427
- if (this.getUnavailableReason(key, serverConfig.command)) return null;
507
+ const retryFailedStart = options.retryFailedStart === true;
508
+ if (retryFailedStart) this.unavailable.delete(key);
509
+ if (!retryFailedStart && this.getUnavailableReason(key, serverConfig.command)) return null;
428
510
 
429
511
  // Return existing client
430
512
  const existing = this.clients.get(key);
@@ -440,27 +522,34 @@ export class LspManager {
440
522
  if (existing && existing.status === "error") {
441
523
  this.clients.delete(key);
442
524
  this.unavailable.set(key, "runtime-error");
525
+ this.failedStarts.set(key, {
526
+ serverName,
527
+ root,
528
+ failureMessage: "The LSP client is not running.",
529
+ });
443
530
  this.clearWarmedWorkspaceSymbolProjects(existing.name, existing.root);
444
531
  this.clearWarmedSemanticProjects(existing.name, existing.root);
445
532
  this.clearPendingWarmProbes(existing.name, existing.root);
446
- return null;
533
+ if (!retryFailedStart) return null;
534
+ this.unavailable.delete(key);
447
535
  }
448
536
 
449
537
  // Deduplicate concurrent starts for the same server:root pair.
450
538
  // This prevents spawning duplicate server processes when two
451
539
  // callers race through getClientForFile before either await yields.
452
540
  const pending = this.pendingStarts.get(key);
453
- if (pending) return pending;
541
+ if (pending) return raceRequestControl(pending, control);
454
542
 
455
543
  const startPromise = this.performStart(serverName, serverConfig, root, key);
456
544
  this.pendingStarts.set(key, startPromise);
457
- try {
458
- return await startPromise;
459
- } finally {
460
- if (this.pendingStarts.get(key) === startPromise) {
461
- this.pendingStarts.delete(key);
462
- }
463
- }
545
+ // Keep the shared startup registered until it settles. A cancelled
546
+ // caller only stops waiting; it must not allow a duplicate route start.
547
+ void startPromise
548
+ .finally(() => {
549
+ if (this.pendingStarts.get(key) === startPromise) this.pendingStarts.delete(key);
550
+ })
551
+ .catch(() => {});
552
+ return raceRequestControl(startPromise, control);
464
553
  }
465
554
 
466
555
  private createClient(
@@ -505,6 +594,11 @@ export class LspManager {
505
594
  }
506
595
  }
507
596
 
597
+ if (kind === "tracked-files" && this.trackedFilesBatchDepth > 0) {
598
+ this.trackedFilesBatchChanged = true;
599
+ return;
600
+ }
601
+
508
602
  const aggregateKind =
509
603
  kind === "readiness" && generation > 1 && client.ready ? "recovery" : kind;
510
604
  this.publishLifecycle(aggregateKind);
@@ -558,18 +652,318 @@ export class LspManager {
558
652
  recovery: ProcessCrashRecoveryState,
559
653
  route: FileRoute,
560
654
  control?: CodeRequestControl,
655
+ options: { allowExhausted?: boolean } = {},
561
656
  ): Promise<LspClient | null> {
562
657
  if (recovery.pending) return this.waitForProcessCrashRecovery(recovery, control);
563
- if (
564
- recovery.statusReason === "process-crashed" &&
565
- !recovery.attemptConsumed &&
566
- !this.shuttingDown
567
- ) {
658
+ const automaticAttempt =
659
+ recovery.statusReason === "process-crashed" && !recovery.attemptConsumed;
660
+ const explicitAttempt =
661
+ options.allowExhausted === true &&
662
+ recovery.statusReason === "process-crash-recovery-exhausted";
663
+ if ((automaticAttempt || explicitAttempt) && !this.shuttingDown) {
568
664
  return this.startProcessCrashRecovery(recovery, route, control);
569
665
  }
570
666
  return Promise.resolve(null);
571
667
  }
572
668
 
669
+ /** Retry failed routes selected by one explicit health refresh. */
670
+ private async recoverExplicitRouteDemand(
671
+ demand: { scopes?: readonly string[] },
672
+ control?: CodeRequestControl,
673
+ ): Promise<ProcessCrashDemandResult> {
674
+ throwIfCodeRequestInterrupted(control);
675
+ const scopes = this.processCrashDiagnosticScopes(demand);
676
+ const candidates = this.selectExplicitRouteCandidates(scopes);
677
+ return this.withExplicitRouteAttempts(candidates, control, async (results) => {
678
+ throwIfCodeRequestInterrupted(control);
679
+ for (const result of results) {
680
+ if (result.kind === "process-crash" && result.attempted) {
681
+ this.recoveryRestartEpochs.set(result.candidate.route.key, this.invalidationEpoch);
682
+ }
683
+ }
684
+ this.observeExplicitReadyRoutes(scopes);
685
+ const classified = this.classifyExplicitRouteAttempts(results, scopes);
686
+ return {
687
+ hasSupport: candidates.length > 0,
688
+ requiresFreshEvidence: results.some((result) => result.attempted),
689
+ startupRetry: buildStartupRetryReport(classified.startupResults, this.cwd),
690
+ processCrashRecovery: buildProcessCrashRecoveryReport(classified.processResults, this.cwd),
691
+ failures: classified.failures,
692
+ failedFiles: Array.from(new Set(classified.failedFiles)),
693
+ };
694
+ });
695
+ }
696
+
697
+ private classifyExplicitRouteAttempts(
698
+ results: readonly ExplicitRouteAttemptResult[],
699
+ scopes: readonly string[],
700
+ ): {
701
+ startupResults: StartupRetryRouteResult[];
702
+ processResults: ProcessCrashRecoveryRouteResult[];
703
+ failures: string[];
704
+ failedFiles: string[];
705
+ } {
706
+ const classified: {
707
+ startupResults: StartupRetryRouteResult[];
708
+ processResults: ProcessCrashRecoveryRouteResult[];
709
+ failures: string[];
710
+ failedFiles: string[];
711
+ } = {
712
+ startupResults: [],
713
+ processResults: [],
714
+ failures: [],
715
+ failedFiles: [],
716
+ } satisfies {
717
+ startupResults: StartupRetryRouteResult[];
718
+ processResults: ProcessCrashRecoveryRouteResult[];
719
+ failures: string[];
720
+ failedFiles: string[];
721
+ };
722
+ for (const result of results) {
723
+ const outcome = this.classifyExplicitRouteAttempt(result, scopes);
724
+ if (outcome.startupResult) classified.startupResults.push(outcome.startupResult);
725
+ if (outcome.processResult) classified.processResults.push(outcome.processResult);
726
+ if (outcome.failure) classified.failures.push(outcome.failure);
727
+ classified.failedFiles.push(...outcome.failedFiles);
728
+ }
729
+ return classified;
730
+ }
731
+
732
+ private classifyExplicitRouteAttempt(
733
+ result: ExplicitRouteAttemptResult,
734
+ scopes: readonly string[],
735
+ ): ExplicitRouteClassification {
736
+ if (!result.attempted) return { failedFiles: [] };
737
+ const recovered = result.client?.status === "running";
738
+ const startup = this.classifyExplicitStartupAttempt(result, recovered);
739
+ if (startup) return startup;
740
+ if (result.kind !== "process-crash") return { failedFiles: [] };
741
+ return this.classifyExplicitProcessAttempt(result, scopes, recovered);
742
+ }
743
+
744
+ private classifyExplicitStartupAttempt(
745
+ result: ExplicitRouteAttemptResult,
746
+ recovered: boolean,
747
+ ): ExplicitRouteClassification | null {
748
+ if (result.kind !== "startup" && !(result.kind === "discovery" && !recovered)) {
749
+ return null;
750
+ }
751
+ const { route } = result.candidate;
752
+ return {
753
+ startupResult: {
754
+ name: route.serverName,
755
+ root: route.root,
756
+ outcome: recovered ? "recovered" : "retry-failed",
757
+ ...(recovered || !result.failureMessage ? {} : { failureMessage: result.failureMessage }),
758
+ },
759
+ ...(recovered
760
+ ? {}
761
+ : { failure: this.formatStartupRetryFailure(route, result.failureMessage) }),
762
+ failedFiles: [],
763
+ };
764
+ }
765
+
766
+ private classifyExplicitProcessAttempt(
767
+ result: ExplicitRouteAttemptResult,
768
+ scopes: readonly string[],
769
+ recovered: boolean,
770
+ ): ExplicitRouteClassification {
771
+ const { route } = result.candidate;
772
+ const processResult: ProcessCrashRecoveryRouteResult = {
773
+ name: route.serverName,
774
+ root: route.root,
775
+ outcome: recovered ? "recovered" : "recovery-failed",
776
+ ...(!recovered && result.failureMessage ? { failureMessage: result.failureMessage } : {}),
777
+ };
778
+ return {
779
+ processResult,
780
+ ...(recovered
781
+ ? {}
782
+ : {
783
+ failure: this.formatProcessCrashDemandFailure(
784
+ { name: route.serverName, root: route.root, outcome: "recovery-failed" },
785
+ "recovery-failed",
786
+ ),
787
+ }),
788
+ failedFiles: recovered ? [] : this.explicitFailedFiles(result.candidate, scopes),
789
+ };
790
+ }
791
+
792
+ private selectExplicitRouteCandidates(scopes: readonly string[]): ExplicitRouteCandidate[] {
793
+ const candidates = new Map<string, ExplicitRouteCandidate>();
794
+ const add = (serverName: string, root: string): void => {
795
+ if (!this.automaticPathPolicy.isEligible(root, "directory")) return;
796
+ const serverConfig = this.config.servers[serverName];
797
+ if (!serverConfig) return;
798
+ const route = { serverName, serverConfig, root, key: clientKey(serverName, root) };
799
+ if (!this.explicitRouteInScope(root, scopes)) return;
800
+ const recovery = this.processCrashRecoveries.get(route.key);
801
+ const startupFailure = this.failedStarts.get(route.key);
802
+ if (this.clients.get(route.key)?.status === "running" && !recovery?.statusReason) {
803
+ candidates.set(route.key, { route });
804
+ return;
805
+ }
806
+ candidates.set(route.key, {
807
+ route,
808
+ ...(recovery ? { processCrashRecovery: recovery } : {}),
809
+ ...(startupFailure ? { startupFailure } : {}),
810
+ });
811
+ };
812
+
813
+ const exactFile =
814
+ scopes.length === 1 && this.inspectSourceFile(scopes[0]) === "regular" ? scopes[0] : null;
815
+ const exactRoute = exactFile ? this.resolveFileRoute(exactFile) : null;
816
+ if (exactRoute) {
817
+ add(exactRoute.serverName, exactRoute.root);
818
+ return Array.from(candidates.values());
819
+ }
820
+
821
+ const detected = scanProjectCapabilities(
822
+ this.config,
823
+ this.cwd,
824
+ undefined,
825
+ this.automaticPathPolicy,
826
+ );
827
+ for (const entry of detected) {
828
+ rememberKnownRoot(this.knownRoots, entry.name, entry.root);
829
+ add(entry.name, entry.root);
830
+ }
831
+ for (const failure of this.failedStarts.values()) add(failure.serverName, failure.root);
832
+ for (const snapshot of this.snapshotProcessCrashRoutes()) add(snapshot.name, snapshot.root);
833
+ return Array.from(candidates.values()).sort(
834
+ (first, second) =>
835
+ first.route.root.localeCompare(second.route.root) ||
836
+ first.route.serverName.localeCompare(second.route.serverName),
837
+ );
838
+ }
839
+
840
+ private explicitRouteInScope(root: string, scopes: readonly string[]): boolean {
841
+ return scopes.some(
842
+ (scope) =>
843
+ projectRoots.isWithinOrEqual(scope, root) || projectRoots.isWithinOrEqual(root, scope),
844
+ );
845
+ }
846
+
847
+ private async withExplicitRouteAttempts<T>(
848
+ candidates: readonly ExplicitRouteCandidate[],
849
+ control: CodeRequestControl | undefined,
850
+ callback: (results: ExplicitRouteAttemptResult[]) => Promise<T>,
851
+ ): Promise<T> {
852
+ throwIfCodeRequestInterrupted(control);
853
+ const leased: string[] = [];
854
+ try {
855
+ const promises = candidates.map((candidate) => {
856
+ throwIfCodeRequestInterrupted(control);
857
+ const key = candidate.route.key;
858
+ const existing = this.explicitRouteAttempts.get(key);
859
+ if (existing) {
860
+ existing.users++;
861
+ leased.push(key);
862
+ return raceRequestControl(existing.promise, control);
863
+ }
864
+ const promise = this.performExplicitRouteAttempt(candidate);
865
+ const lease: ExplicitRouteAttemptLease = { promise, users: 1, settled: false };
866
+ this.explicitRouteAttempts.set(key, lease);
867
+ void promise
868
+ .finally(() => {
869
+ lease.settled = true;
870
+ if (lease.users === 0 && this.explicitRouteAttempts.get(key) === lease) {
871
+ this.explicitRouteAttempts.delete(key);
872
+ }
873
+ })
874
+ .catch(() => {});
875
+ leased.push(key);
876
+ return raceRequestControl(promise, control);
877
+ });
878
+ const results = await Promise.all(promises);
879
+ return await callback(results);
880
+ } finally {
881
+ for (const key of leased) {
882
+ const lease = this.explicitRouteAttempts.get(key);
883
+ if (!lease) continue;
884
+ lease.users--;
885
+ if (lease.users === 0 && lease.settled) this.explicitRouteAttempts.delete(key);
886
+ }
887
+ }
888
+ }
889
+
890
+ private async performExplicitRouteAttempt(
891
+ candidate: ExplicitRouteCandidate,
892
+ ): Promise<ExplicitRouteAttemptResult> {
893
+ const { route } = candidate;
894
+ const recovery = this.processCrashRecoveries.get(route.key) ?? candidate.processCrashRecovery;
895
+ if (recovery?.statusReason) {
896
+ const client = await this.acquireProcessCrashReplacement(recovery, route, undefined, {
897
+ allowExhausted: true,
898
+ });
899
+ return {
900
+ candidate,
901
+ kind: "process-crash",
902
+ client,
903
+ attempted: true,
904
+ ...(recovery.failureMessage ? { failureMessage: recovery.failureMessage } : {}),
905
+ };
906
+ }
907
+
908
+ const existing = this.clients.get(route.key);
909
+ if (existing?.status === "running") {
910
+ return { candidate, kind: "none", client: existing, attempted: false };
911
+ }
912
+ const retryStartup = candidate.startupFailure !== undefined || existing?.status === "error";
913
+ const client = await this.startServerForRoot(route.serverName, route.root, undefined, {
914
+ retryFailedStart: true,
915
+ });
916
+ const failureMessage =
917
+ this.failedStarts.get(route.key)?.failureMessage ?? candidate.startupFailure?.failureMessage;
918
+ return {
919
+ candidate,
920
+ kind: retryStartup ? "startup" : "discovery",
921
+ client,
922
+ attempted: true,
923
+ ...(failureMessage ? { failureMessage } : {}),
924
+ };
925
+ }
926
+
927
+ private observeExplicitReadyRoutes(scopes: readonly string[]): void {
928
+ for (const client of this.clients.values()) {
929
+ if (client.status !== "running" || !client.ready) continue;
930
+ if (!this.explicitRouteInScope(client.root, scopes)) continue;
931
+ this.observeExplicitRouteReady(client);
932
+ }
933
+ }
934
+
935
+ private observeExplicitRouteReady(client: LspClient): void {
936
+ if (client.status !== "running" || !client.ready) return;
937
+ const key = clientKey(client.name, client.root);
938
+ const recovery = this.processCrashRecoveries.get(key);
939
+ if (recovery) {
940
+ recovery.attemptConsumed = false;
941
+ recovery.statusReason = undefined;
942
+ recovery.failureMessage = undefined;
943
+ this.processCrashRecoveries.delete(key);
944
+ }
945
+ this.failedStarts.delete(key);
946
+ this.unavailable.delete(key);
947
+ }
948
+
949
+ private explicitFailedFiles(
950
+ candidate: ExplicitRouteCandidate,
951
+ scopes: readonly string[],
952
+ ): string[] {
953
+ const files = candidate.processCrashRecovery?.files ?? [];
954
+ return files.filter(
955
+ (file) =>
956
+ this.isDiagnosticFile(file) &&
957
+ scopes.some((scope) => projectRoots.isWithinOrEqual(scope, file)),
958
+ );
959
+ }
960
+
961
+ private formatStartupRetryFailure(route: FileRoute, failureMessage?: string): string {
962
+ const root = (path.relative(this.cwd, route.root) || ".").replaceAll("\\", "/");
963
+ const detail = failureMessage ? `: ${boundProcessCrashFailureMessage(failureMessage)}` : "";
964
+ return `${route.serverName} @ ${root} is unavailable — startup retry failed${detail}; try another explicit health refresh.`;
965
+ }
966
+
573
967
  /**
574
968
  * Recover all crashed routes selected by one evidence-producing operation.
575
969
  * Each route keeps its independent attempt budget and shared replacement.
@@ -594,6 +988,7 @@ export class LspManager {
594
988
  return {
595
989
  hasSupport,
596
990
  requiresFreshEvidence: false,
991
+ startupRetry: emptyStartupRetryReport(),
597
992
  processCrashRecovery: buildProcessCrashRecoveryReport(
598
993
  immediate.map(({ route }) => route),
599
994
  this.cwd,
@@ -646,6 +1041,7 @@ export class LspManager {
646
1041
  return {
647
1042
  hasSupport,
648
1043
  requiresFreshEvidence: observedRequired.length > 0,
1044
+ startupRetry: emptyStartupRetryReport(),
649
1045
  processCrashRecovery: buildProcessCrashRecoveryReport(
650
1046
  [...immediate.map(({ route }) => route), ...recoveredRoutes],
651
1047
  this.cwd,
@@ -756,8 +1152,8 @@ export class LspManager {
756
1152
  : "recovery-failed");
757
1153
  const reason =
758
1154
  routeOutcome === "recovery-exhausted"
759
- ? "process recovery exhausted; reload required"
760
- : "process recovery failed";
1155
+ ? "process recovery exhausted; try another explicit health refresh"
1156
+ : "process recovery failed; try another explicit health refresh";
761
1157
  return `${name} @ ${root} is unavailable — ${reason}.`;
762
1158
  }
763
1159
 
@@ -996,6 +1392,11 @@ export class LspManager {
996
1392
  // Validate command exists
997
1393
  if (!commandExists(serverConfig.command)) {
998
1394
  this.unavailable.set(key, "missing-command");
1395
+ this.failedStarts.set(key, {
1396
+ serverName,
1397
+ root,
1398
+ failureMessage: `Configured server command is not available: ${serverConfig.command}`,
1399
+ });
999
1400
  return null;
1000
1401
  }
1001
1402
 
@@ -1014,9 +1415,15 @@ export class LspManager {
1014
1415
  return null;
1015
1416
  }
1016
1417
  this.unavailable.delete(key);
1418
+ this.failedStarts.delete(key);
1017
1419
  return client;
1018
- } catch {
1420
+ } catch (error) {
1019
1421
  this.unavailable.set(key, "start-failed");
1422
+ this.failedStarts.set(key, {
1423
+ serverName,
1424
+ root,
1425
+ ...(error instanceof Error && error.message ? { failureMessage: error.message } : {}),
1426
+ });
1020
1427
  if (this.clients.get(key) === client) this.clients.delete(key);
1021
1428
  return null;
1022
1429
  }
@@ -1095,7 +1502,7 @@ export class LspManager {
1095
1502
  seen: Set<string>,
1096
1503
  ): { client: LspClient; key: string } | null {
1097
1504
  const client = this.getExistingClientForFile(filePath);
1098
- if (!client || (pushOnly && client.hasDiagnosticProvider)) return null;
1505
+ if (!client || (pushOnly && client.hasDiagnosticRequestAdapter)) return null;
1099
1506
 
1100
1507
  const key = clientKey(client.name, client.root);
1101
1508
  if (this.processCrashRecoveries.get(key)?.pending || seen.has(key)) return null;
@@ -1196,6 +1603,17 @@ export class LspManager {
1196
1603
  fileTypes: [...(this.config.servers[client.name]?.fileTypes ?? [])],
1197
1604
  });
1198
1605
  }
1606
+ // Keep an initial-start failure visible after its failed client is
1607
+ // removed from the active client pool.
1608
+ for (const failure of this.failedStarts.values()) {
1609
+ const key = clientKey(failure.serverName, failure.root);
1610
+ if (known.has(key)) continue;
1611
+ known.set(key, {
1612
+ name: failure.serverName,
1613
+ root: failure.root,
1614
+ fileTypes: [...(this.config.servers[failure.serverName]?.fileTypes ?? [])],
1615
+ });
1616
+ }
1199
1617
  // Keep an exhausted recovery route visible after its failed replacement
1200
1618
  // is removed from the active client pool.
1201
1619
  for (const recovery of this.processCrashRecoveries.values()) {
@@ -1223,10 +1641,115 @@ export class LspManager {
1223
1641
  async waitUntilFileReady(
1224
1642
  filePath: string,
1225
1643
  control?: CodeRequestControl,
1226
- onProcessCrashRecovery?: (report: ProcessCrashRecoveryReport) => void,
1644
+ options: { retryFailedRoute?: boolean } & FileReadinessCallbacks = {},
1227
1645
  ): Promise<FileReadinessResult> {
1228
1646
  throwIfCodeRequestInterrupted(control);
1229
1647
  const resolvedPath = resolveSessionPath(this.cwd, filePath);
1648
+ if (options.retryFailedRoute) {
1649
+ const route = this.resolveFileRoute(resolvedPath);
1650
+ if (!route || !this.automaticPathPolicy.isEligible(route.root, "directory")) {
1651
+ return {
1652
+ client: null,
1653
+ processCrashRecovery: emptyProcessCrashRecoveryReport(),
1654
+ startupRetry: emptyStartupRetryReport(),
1655
+ };
1656
+ }
1657
+ const candidates: ExplicitRouteCandidate[] = [
1658
+ {
1659
+ route,
1660
+ ...(this.processCrashRecoveries.get(route.key)
1661
+ ? { processCrashRecovery: this.processCrashRecoveries.get(route.key) }
1662
+ : {}),
1663
+ ...(this.failedStarts.get(route.key)
1664
+ ? { startupFailure: this.failedStarts.get(route.key) }
1665
+ : {}),
1666
+ },
1667
+ ];
1668
+ return this.withExplicitRouteAttempts(candidates, control, (results) =>
1669
+ this.completeExplicitFileReadiness(resolvedPath, control, options, results[0]),
1670
+ );
1671
+ }
1672
+ return this.waitUntilFileReadyInternal(resolvedPath, control, options.onProcessCrashRecovery);
1673
+ }
1674
+
1675
+ private async completeExplicitFileReadiness(
1676
+ resolvedPath: string,
1677
+ control: CodeRequestControl | undefined,
1678
+ callbacks: FileReadinessCallbacks,
1679
+ result: ExplicitRouteAttemptResult | undefined,
1680
+ ): Promise<FileReadinessResult> {
1681
+ const startupRetry = result
1682
+ ? this.buildExplicitFileStartupReport(result)
1683
+ : emptyStartupRetryReport();
1684
+ if (startupRetry.entries.length > 0) callbacks.onStartupRetry?.(startupRetry);
1685
+ let readiness: FileReadinessResult;
1686
+ try {
1687
+ readiness = await this.waitUntilFileReadyInternal(
1688
+ resolvedPath,
1689
+ control,
1690
+ callbacks.onProcessCrashRecovery,
1691
+ );
1692
+ } catch (error) {
1693
+ if (result?.kind !== "process-crash" || isCodeRequestInterruption(error, control)) {
1694
+ throw error;
1695
+ }
1696
+ const processCrashRecovery = this.buildExplicitFileProcessCrashReport(result, null, error);
1697
+ callbacks.onProcessCrashRecovery?.(processCrashRecovery);
1698
+ return {
1699
+ client: null,
1700
+ processCrashRecovery,
1701
+ startupRetry,
1702
+ };
1703
+ }
1704
+ if (!result) return readiness;
1705
+ if (readiness.client?.ready) this.observeExplicitRouteReady(readiness.client);
1706
+ const processCrashRecovery =
1707
+ result.kind === "process-crash"
1708
+ ? this.mergeExplicitFileProcessCrashReports(
1709
+ this.buildExplicitFileProcessCrashReport(result, readiness.client),
1710
+ readiness.processCrashRecovery,
1711
+ )
1712
+ : readiness.processCrashRecovery;
1713
+ if (processCrashRecovery.entries.length > 0) {
1714
+ callbacks.onProcessCrashRecovery?.(processCrashRecovery);
1715
+ }
1716
+ return {
1717
+ client: readiness.client,
1718
+ processCrashRecovery,
1719
+ startupRetry,
1720
+ };
1721
+ }
1722
+
1723
+ private mergeExplicitFileProcessCrashReports(
1724
+ explicit: ProcessCrashRecoveryReport,
1725
+ observed: ProcessCrashRecoveryReport,
1726
+ ): ProcessCrashRecoveryReport {
1727
+ if (observed.failedRoutes === 0) return explicit;
1728
+ const observedFailure = observed.entries.find(
1729
+ (entry) => entry.outcome === "recovery-failed" && entry.failureMessage,
1730
+ );
1731
+ if (!observedFailure?.failureMessage) return explicit;
1732
+ return {
1733
+ ...explicit,
1734
+ entries: explicit.entries.map((entry) =>
1735
+ entry.name === observedFailure.name && entry.root === observedFailure.root
1736
+ ? { ...entry, failureMessage: observedFailure.failureMessage }
1737
+ : entry,
1738
+ ),
1739
+ };
1740
+ }
1741
+
1742
+ private buildExplicitFileStartupReport(result: ExplicitRouteAttemptResult): StartupRetryReport {
1743
+ return result.kind === "startup" || (result.kind === "discovery" && !result.client)
1744
+ ? this.buildExplicitFileStartupRetryReport(result)
1745
+ : emptyStartupRetryReport();
1746
+ }
1747
+
1748
+ private async waitUntilFileReadyInternal(
1749
+ resolvedPath: string,
1750
+ control: CodeRequestControl | undefined,
1751
+ onProcessCrashRecovery?: (report: ProcessCrashRecoveryReport) => void,
1752
+ ): Promise<FileReadinessResult> {
1230
1753
  const route = this.resolveFileRoute(resolvedPath);
1231
1754
  const recovery = route ? this.processCrashRecoveries.get(route.key) : undefined;
1232
1755
  const statusAtStart = recovery?.statusReason;
@@ -1242,7 +1765,13 @@ export class LspManager {
1242
1765
  client,
1243
1766
  );
1244
1767
  if (processCrashRecoveryRequested) onProcessCrashRecovery?.(processCrashRecovery);
1245
- if (!client) return { client: null, processCrashRecovery };
1768
+ if (!client) {
1769
+ return {
1770
+ client: null,
1771
+ processCrashRecovery,
1772
+ startupRetry: emptyStartupRetryReport(),
1773
+ };
1774
+ }
1246
1775
  try {
1247
1776
  await client.getReady(control);
1248
1777
  await this.warmSemanticProject(client, resolvedPath, true, control);
@@ -1256,11 +1785,62 @@ export class LspManager {
1256
1785
  );
1257
1786
  if (failedRecovery) {
1258
1787
  onProcessCrashRecovery?.(failedRecovery);
1259
- return { client: null, processCrashRecovery: failedRecovery };
1788
+ return {
1789
+ client: null,
1790
+ processCrashRecovery: failedRecovery,
1791
+ startupRetry: emptyStartupRetryReport(),
1792
+ };
1260
1793
  }
1261
1794
  throw error;
1262
1795
  }
1263
- return { client, processCrashRecovery };
1796
+ return {
1797
+ client,
1798
+ processCrashRecovery,
1799
+ startupRetry: emptyStartupRetryReport(),
1800
+ };
1801
+ }
1802
+
1803
+ private buildExplicitFileStartupRetryReport(
1804
+ result: ExplicitRouteAttemptResult,
1805
+ ): StartupRetryReport {
1806
+ const recovered = result.client?.status === "running";
1807
+ return buildStartupRetryReport(
1808
+ [
1809
+ {
1810
+ name: result.candidate.route.serverName,
1811
+ root: result.candidate.route.root,
1812
+ outcome: recovered ? "recovered" : "retry-failed",
1813
+ ...(recovered || !result.failureMessage ? {} : { failureMessage: result.failureMessage }),
1814
+ },
1815
+ ],
1816
+ this.cwd,
1817
+ );
1818
+ }
1819
+
1820
+ private buildExplicitFileProcessCrashReport(
1821
+ result: ExplicitRouteAttemptResult,
1822
+ client: LspClient | null,
1823
+ failure?: unknown,
1824
+ ): ProcessCrashRecoveryReport {
1825
+ const failureMessage =
1826
+ failure instanceof Error && failure.message
1827
+ ? boundProcessCrashFailureMessage(failure.message)
1828
+ : result.failureMessage;
1829
+ return buildProcessCrashRecoveryReport(
1830
+ [
1831
+ {
1832
+ name: result.candidate.route.serverName,
1833
+ root: result.candidate.route.root,
1834
+ outcome: client?.status === "running" ? "recovered" : "recovery-failed",
1835
+ ...(!client?.status || client.status !== "running"
1836
+ ? failureMessage
1837
+ ? { failureMessage }
1838
+ : {}
1839
+ : {}),
1840
+ },
1841
+ ],
1842
+ this.cwd,
1843
+ );
1264
1844
  }
1265
1845
 
1266
1846
  private buildFileProcessCrashRecoveryReport(
@@ -1377,7 +1957,10 @@ export class LspManager {
1377
1957
  return unavailableCodeQuery(`No LSP client can collect diagnostics for ${resolvedPath}.`);
1378
1958
  }
1379
1959
  try {
1380
- return await syncClientFileAndGetDiagnostics(client, resolvedPath, maxSeverity, control);
1960
+ return await syncClientFileAndGetDiagnostics(client, resolvedPath, maxSeverity, {
1961
+ control,
1962
+ contentIsAuthoritative: false,
1963
+ });
1381
1964
  } catch (error) {
1382
1965
  // Cancellation must propagate; a cancelled caller no longer awaits a
1383
1966
  // diagnostic result or the file cleanup side effects.
@@ -1387,6 +1970,118 @@ export class LspManager {
1387
1970
  return unavailableCodeQuery(`Diagnostic collection failed for ${resolvedPath}: ${detail}`);
1388
1971
  }
1389
1972
  }
1973
+ /**
1974
+ * Track a bounded set of automatic source files with shared route starts.
1975
+ * Inputs after the first 256 unique paths are ignored.
1976
+ */
1977
+ async bulkTrackFiles(
1978
+ filePaths: readonly string[],
1979
+ control?: CodeRequestControl,
1980
+ ): Promise<BulkTrackFilesResult> {
1981
+ throwIfCodeRequestInterrupted(control);
1982
+ const selected = Array.from(
1983
+ new Set(filePaths.map((filePath) => resolveSessionPath(this.cwd, filePath))),
1984
+ ).slice(0, MAX_BULK_TRACK_FILES);
1985
+ if (selected.length === 0) return { outcomes: [] };
1986
+
1987
+ this.trackedFilesBatchDepth++;
1988
+ const outcomes = new Array<BulkTrackFileOutcome>(selected.length);
1989
+ const workerCount = Math.min(MAX_CONCURRENT_BULK_TRACKS, selected.length);
1990
+ let nextIndex = 0;
1991
+ const worker = async (): Promise<void> => {
1992
+ for (;;) {
1993
+ throwIfCodeRequestInterrupted(control);
1994
+ const index = nextIndex++;
1995
+ if (index >= selected.length) return;
1996
+ outcomes[index] = await this.trackBulkFile(selected[index], control);
1997
+ }
1998
+ };
1999
+ const workers = Array.from({ length: workerCount }, () => worker());
2000
+ try {
2001
+ await Promise.all(workers);
2002
+ return { outcomes };
2003
+ } catch (error) {
2004
+ // Let every worker observe cancellation before the batch transition is
2005
+ // published. Shared route startup may continue, but no worker may open
2006
+ // another document after this caller stops waiting.
2007
+ await Promise.allSettled(workers);
2008
+ throw error;
2009
+ } finally {
2010
+ this.trackedFilesBatchDepth--;
2011
+ if (this.trackedFilesBatchDepth === 0 && this.trackedFilesBatchChanged) {
2012
+ this.trackedFilesBatchChanged = false;
2013
+ this.publishLifecycle("tracked-files");
2014
+ }
2015
+ }
2016
+ }
2017
+
2018
+ private async trackBulkFile(
2019
+ filePath: string,
2020
+ control?: CodeRequestControl,
2021
+ ): Promise<BulkTrackFileOutcome> {
2022
+ const resolvedPath = resolveSessionPath(this.cwd, filePath);
2023
+ if (!this.isConfiguredAutomaticSourceFile(resolvedPath)) {
2024
+ return { file: resolvedPath, kind: "skipped", reason: "not-automatic-source" };
2025
+ }
2026
+ const fileStatus = this.inspectSourceFile(resolvedPath);
2027
+ if (fileStatus !== "regular") {
2028
+ if (fileStatus === "missing") {
2029
+ return { file: resolvedPath, kind: "skipped", reason: "missing" };
2030
+ }
2031
+ if (fileStatus === "not-automatic-source") {
2032
+ return { file: resolvedPath, kind: "skipped", reason: "not-automatic-source" };
2033
+ }
2034
+ return {
2035
+ file: resolvedPath,
2036
+ kind: "unavailable",
2037
+ reason: "The source file could not be inspected.",
2038
+ };
2039
+ }
2040
+ if (this.isFileTracked(resolvedPath)) {
2041
+ return { file: resolvedPath, kind: "already-tracked" };
2042
+ }
2043
+
2044
+ throwIfCodeRequestInterrupted(control);
2045
+ // Recheck the automatic source-support rule after the inventory scan. A
2046
+ // config, exclusion, or route can change while a refresh is in flight.
2047
+ if (!this.isSupportedSourceFile(resolvedPath)) {
2048
+ return {
2049
+ file: resolvedPath,
2050
+ kind: "unavailable",
2051
+ reason: "No available LSP route can serve this source file.",
2052
+ };
2053
+ }
2054
+
2055
+ const client = await this.ensureFileOpen(resolvedPath, { control });
2056
+ if (!client) {
2057
+ return {
2058
+ file: resolvedPath,
2059
+ kind: "unavailable",
2060
+ reason: "The LSP route could not open this source file.",
2061
+ };
2062
+ }
2063
+ return { file: resolvedPath, kind: "tracked" };
2064
+ }
2065
+
2066
+ private isConfiguredAutomaticSourceFile(filePath: string): boolean {
2067
+ return this.isAutomaticScopePath(filePath) && getServerForFile(this.config, filePath) !== null;
2068
+ }
2069
+
2070
+ private inspectSourceFile(
2071
+ filePath: string,
2072
+ ): "regular" | "missing" | "not-automatic-source" | "unavailable" {
2073
+ try {
2074
+ return fs.statSync(filePath).isFile() ? "regular" : "not-automatic-source";
2075
+ } catch (error) {
2076
+ if (isMissingFileError(error)) return "missing";
2077
+ return "unavailable";
2078
+ }
2079
+ }
2080
+
2081
+ private isFileTracked(filePath: string): boolean {
2082
+ return this.getExistingClientForFile(filePath)?.openFiles.includes(filePath) ?? false;
2083
+ }
2084
+
1390
2085
  /** Close a file across any active LSP clients and clear its cached diagnostics. */
1391
2086
  closeFile(filePath: string): void {
1392
2087
  closeFileAcrossClients(this.clients.values(), resolveSessionPath(this.cwd, filePath));
@@ -1434,13 +2129,13 @@ export class LspManager {
1434
2129
  /** Return per-route diagnostic evidence capability for recovery targeting. */
1435
2130
  getClientDiagnosticRoutes(): Array<{
1436
2131
  key: string;
1437
- supportsPull: boolean;
2132
+ hasDiagnosticRequestAdapter: boolean;
1438
2133
  unconfirmedFiles: string[];
1439
2134
  stallSignal: ReturnType<LspClient["getRecoveryStallSignal"]>;
1440
2135
  }> {
1441
2136
  const routes: Array<{
1442
2137
  key: string;
1443
- supportsPull: boolean;
2138
+ hasDiagnosticRequestAdapter: boolean;
1444
2139
  unconfirmedFiles: string[];
1445
2140
  stallSignal: ReturnType<LspClient["getRecoveryStallSignal"]>;
1446
2141
  }> = [];
@@ -1453,7 +2148,7 @@ export class LspManager {
1453
2148
  .filter((file) => this.isDiagnosticFile(file));
1454
2149
  routes.push({
1455
2150
  key,
1456
- supportsPull: client.hasDiagnosticProvider,
2151
+ hasDiagnosticRequestAdapter: client.hasDiagnosticRequestAdapter,
1457
2152
  unconfirmedFiles,
1458
2153
  stallSignal: client.getRecoveryStallSignal(),
1459
2154
  });
@@ -1469,35 +2164,46 @@ export class LspManager {
1469
2164
  quietMs?: number;
1470
2165
  /** Evidence from a refresh the caller already completed; skips this pass's own refresh when no watched-file changes apply. */
1471
2166
  initialEvidence?: DiagnosticEvidenceSummary;
1472
- /** Explicit demand to recover crashed routes with tracked files in scope. */
1473
- processCrashDemand?: { scopes?: readonly string[] };
2167
+ /** Explicit demand to retry crashed and failed routes in scope. */
2168
+ processCrashDemand?: { scopes?: readonly string[]; explicit?: boolean };
1474
2169
  control?: CodeRequestControl;
1475
2170
  }): Promise<WorkspaceRecoveryResult> {
1476
2171
  const processCrashDemand = options?.processCrashDemand;
1477
- const demand = processCrashDemand
1478
- ? await this.recoverProcessCrashDemand(
1479
- (snapshot) => {
1480
- if (!this.processCrashDiagnosticRouteInScope(snapshot, processCrashDemand)) {
1481
- return null;
1482
- }
1483
- return this.processCrashDiagnosticFiles(snapshot, processCrashDemand);
1484
- },
1485
- options?.control,
1486
- { skipWhenNoRetainedFile: true },
1487
- )
1488
- : {
1489
- hasSupport: false,
1490
- requiresFreshEvidence: false,
1491
- processCrashRecovery: emptyProcessCrashRecoveryReport(),
1492
- failures: [],
1493
- failedFiles: [],
1494
- };
2172
+ const demand = processCrashDemand?.explicit
2173
+ ? await this.recoverExplicitRouteDemand(processCrashDemand, options?.control)
2174
+ : processCrashDemand
2175
+ ? await this.recoverProcessCrashDemand(
2176
+ (snapshot) => {
2177
+ if (!this.processCrashDiagnosticRouteInScope(snapshot, processCrashDemand)) {
2178
+ return null;
2179
+ }
2180
+ return this.processCrashDiagnosticFiles(snapshot, processCrashDemand);
2181
+ },
2182
+ options?.control,
2183
+ { skipWhenNoRetainedFile: true },
2184
+ )
2185
+ : {
2186
+ hasSupport: false,
2187
+ requiresFreshEvidence: false,
2188
+ processCrashRecovery: emptyProcessCrashRecoveryReport(),
2189
+ startupRetry: emptyStartupRetryReport(),
2190
+ failures: [],
2191
+ failedFiles: [],
2192
+ };
1495
2193
  const recoveryOptions = demand.requiresFreshEvidence
1496
2194
  ? { ...options, initialEvidence: undefined }
1497
2195
  : options;
1498
2196
  const result = await recoverWorkspaceDiagnosticsImpl(this, recoveryOptions);
2197
+ if (processCrashDemand?.explicit) {
2198
+ throwIfCodeRequestInterrupted(options?.control);
2199
+ this.observeExplicitReadyRoutes(this.processCrashDiagnosticScopes(processCrashDemand));
2200
+ }
1499
2201
  return this.addProcessCrashDiagnosticEvidence(
1500
- { ...result, processCrashRecovery: demand.processCrashRecovery },
2202
+ {
2203
+ ...result,
2204
+ processCrashRecovery: demand.processCrashRecovery,
2205
+ startupRetry: demand.startupRetry,
2206
+ },
1501
2207
  demand,
1502
2208
  );
1503
2209
  }
@@ -1575,6 +2281,8 @@ export class LspManager {
1575
2281
  this.initializedClientGenerations.clear();
1576
2282
  this.handledCrashGenerations.clear();
1577
2283
  this.processCrashRecoveries.clear();
2284
+ this.failedStarts.clear();
2285
+ this.explicitRouteAttempts.clear();
1578
2286
  this.recoveryRestartEpochs.clear();
1579
2287
  this.unavailable.clear();
1580
2288
  this.knownRoots.clear();
@@ -1851,7 +2559,8 @@ export class LspManager {
1851
2559
  }
1852
2560
 
1853
2561
  /**
1854
- * Aggregate the tsconfig scope decision for every tracked file.
2562
+ * Aggregate the tsconfig scope decision for every tracked TypeScript or
2563
+ * JavaScript-family file.
1855
2564
  *
1856
2565
  * The recovery telemetry surface uses this to report why tracked files are
1857
2566
  * in or out of scope without re-deriving the scope logic. The returned
@@ -1869,7 +2578,7 @@ export class LspManager {
1869
2578
  for (const client of this.clients.values()) {
1870
2579
  for (const document of client.getDiagnosticSnapshot().documents) {
1871
2580
  const file = relativeFilePathFromUri(document.uri, this.cwd);
1872
- if (!this.isAutomaticScopePath(file)) continue;
2581
+ if (!this.isAutomaticScopePath(file) || !isTsconfigApplicableFile(file)) continue;
1873
2582
  totalFiles++;
1874
2583
  accumulateScopeDecision(accumulator, file, getFileScopeDecision(file, this.cwd));
1875
2584
  }
@@ -1994,9 +2703,14 @@ export class LspManager {
1994
2703
  const client = await this.getClientForFile(resolvedPath, options);
1995
2704
  if (!client) return null;
1996
2705
  try {
1997
- client.didOpen(resolvedPath, fs.readFileSync(resolvedPath, "utf-8"));
2706
+ const content = fs.readFileSync(resolvedPath, "utf-8");
2707
+ // Route startup can be shared. The document open belongs to this
2708
+ // caller, so check again after the shared wait before sending didOpen.
2709
+ throwIfCodeRequestInterrupted(options.control);
2710
+ client.didOpen(resolvedPath, content);
1998
2711
  return client;
1999
- } catch {
2712
+ } catch (error) {
2713
+ if (isCodeRequestInterruption(error, options.control)) throw error;
2000
2714
  this.closeFile(resolvedPath);
2001
2715
  return null;
2002
2716
  }