@mrclrchtr/supi-code-intelligence 6.0.0 → 6.1.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 (71) hide show
  1. package/README.md +3 -1
  2. package/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  3. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +5 -0
  5. package/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  6. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  7. package/node_modules/@mrclrchtr/supi-lsp/README.md +7 -1
  8. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  9. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  10. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +5 -0
  11. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  12. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  13. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-jsonrpc/package.json +1 -1
  14. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-protocol/lib/common/protocol.colorProvider.d.ts +1 -1
  15. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-protocol/lib/common/protocol.d.ts +2 -2
  16. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-protocol/lib/common/protocol.fileOperations.d.ts +9 -9
  17. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-protocol/package.json +3 -3
  18. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-types/lib/esm/main.d.ts +2 -2
  19. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-types/lib/esm/main.js +1 -1
  20. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-types/lib/umd/main.d.ts +2 -2
  21. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-types/lib/umd/main.js +1 -1
  22. package/node_modules/@mrclrchtr/supi-lsp/node_modules/vscode-languageserver-types/package.json +1 -1
  23. package/node_modules/@mrclrchtr/supi-lsp/package.json +3 -3
  24. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +2 -0
  25. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +6 -0
  26. package/node_modules/@mrclrchtr/supi-lsp/src/config/server-config.ts +8 -0
  27. package/node_modules/@mrclrchtr/supi-lsp/src/config/types.ts +1 -0
  28. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-project-info.ts +6 -3
  29. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +582 -26
  30. package/node_modules/@mrclrchtr/supi-lsp/src/provider/lsp-semantic-provider.ts +2 -3
  31. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostic-surface.ts +10 -0
  32. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +36 -9
  33. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-transition-debug.ts +2 -0
  34. package/node_modules/@mrclrchtr/supi-lsp/src/session/workspace-lsp-runtime.ts +4 -2
  35. package/node_modules/@mrclrchtr/supi-tree-sitter/README.md +2 -0
  36. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  37. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  38. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +5 -0
  39. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  40. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  41. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm +0 -0
  42. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm.map +4 -4
  43. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/package.json +1 -1
  44. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.wasm +0 -0
  45. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.wasm.map +4 -4
  46. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +3 -3
  47. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/kotlin/tree-sitter-kotlin.wasm.json +1 -1
  48. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/sql/tree-sitter-sql.wasm.json +1 -1
  49. package/package.json +5 -5
  50. package/src/analysis/health/recovery.ts +4 -0
  51. package/src/analysis/health/server-status.ts +64 -0
  52. package/src/analysis/health/signals.ts +1 -0
  53. package/src/analysis/provider.ts +2 -1
  54. package/src/analysis/target/symbol.ts +28 -16
  55. package/src/analysis/target/types.ts +1 -0
  56. package/src/session/find-workflow.ts +5 -12
  57. package/src/session/health-refresh.ts +6 -2
  58. package/src/session/health-types.ts +8 -2
  59. package/src/session/health-workflow.ts +5 -1
  60. package/src/session/semantic-demand.ts +36 -0
  61. package/src/session/target-workflow.ts +9 -12
  62. package/src/tool/code_health/guidance.ts +2 -2
  63. package/src/tool/code_health/markdown.ts +15 -3
  64. package/src/tool/code_health/result.ts +14 -5
  65. package/src/tool/code_health/spec.ts +2 -1
  66. package/src/tool/code_health/tui.ts +24 -3
  67. package/src/tool/result/errors.ts +1 -0
  68. package/src/tool/result/types.ts +6 -0
  69. package/src/ui/footer.ts +7 -5
  70. package/src/ui/status-command.ts +31 -0
  71. package/src/ui/status-overlay.ts +69 -23
@@ -21,6 +21,7 @@ import type { ClientDiagnosticSnapshot } from "../client/client-document-state.t
21
21
 
22
22
  export { RECOVERY_CLIENT_STARTUP_BOUND_MS } from "../client/client.ts";
23
23
 
24
+ import { recordDebugEvent } from "@mrclrchtr/supi-core/debug";
24
25
  import { getServerForFile } from "../config/config.ts";
25
26
  import { getFileScopeDecision } from "../config/tsconfig-scope.ts";
26
27
  import type {
@@ -29,10 +30,12 @@ import type {
29
30
  FileEvent,
30
31
  LspConfig,
31
32
  ProjectServerInfo,
33
+ ProjectServerStatusReason,
32
34
  ServerConfig,
33
35
  SymbolInformation,
34
36
  WorkspaceSymbol,
35
37
  } from "../config/types.ts";
38
+ import { boundCwd, truncateIdentity } from "../debug-telemetry.ts";
36
39
  import {
37
40
  accumulateOutstandingDiagnostics,
38
41
  collectDiagnosticSummaryCounts,
@@ -151,6 +154,45 @@ export interface ManagerLifecycleTransition {
151
154
 
152
155
  type ManagerLifecycleListener = (transition: ManagerLifecycleTransition) => void;
153
156
 
157
+ type FileClientRequestOptions = {
158
+ /** Allow this request to start the route's one process-crash recovery. */
159
+ recoverProcessCrash?: boolean;
160
+ /** Control only this caller's wait for a shared replacement. */
161
+ control?: CodeRequestControl;
162
+ };
163
+
164
+ interface ProcessCrashRecoveryState {
165
+ /** The one automatic attempt is consumed when a replacement starts. */
166
+ attemptConsumed: boolean;
167
+ /** Current user-facing reason, or undefined after successful recovery. */
168
+ statusReason: ProjectServerStatusReason | undefined;
169
+ /** The failed generation whose tracked files must be restored. */
170
+ failedClient: LspClient;
171
+ /** Files tracked by the failed route at the time of the crash. */
172
+ files: string[];
173
+ /** Shared replacement startup and document-restoration operation. */
174
+ pending: Promise<LspClient | null> | null;
175
+ /** Start time for the current replacement attempt, when one exists. */
176
+ attemptStartedAt?: number;
177
+ }
178
+
179
+ type ProcessCrashRecoveryOutcome = "attempt" | "success" | "failure" | "exhausted" | "cancelled";
180
+
181
+ type ProcessCrashDemandResult = {
182
+ /** Whether at least one required crashed route supports the operation. */
183
+ hasSupport: boolean;
184
+ /** Required routes that remain unavailable after shared recovery settles. */
185
+ failures: string[];
186
+ /** In-scope tracked files from required routes that remain unavailable. */
187
+ failedFiles: string[];
188
+ };
189
+
190
+ type RequiredProcessCrashDemand = {
191
+ key: string;
192
+ recovery: ProcessCrashRecoveryState;
193
+ failedFiles: readonly string[];
194
+ };
195
+
154
196
  // ── LspManager ────────────────────────────────────────────────────────
155
197
  export class LspManager {
156
198
  /** Active clients keyed by "serverName:root" */
@@ -177,6 +219,14 @@ export class LspManager {
177
219
  private warmedSemanticProjects = new Set<string>();
178
220
  /** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
179
221
  private pendingWarmProbes = new Map<string, Promise<void>>();
222
+ /** One process-crash recovery budget and shared replacement per route. */
223
+ private processCrashRecoveries = new Map<string, ProcessCrashRecoveryState>();
224
+ /** Client generations that completed the initialize handshake. */
225
+ private initializedClientGenerations = new Map<string, number>();
226
+ /** Client generations whose process-failure fact was already handled. */
227
+ private handledCrashGenerations = new Map<string, number>();
228
+ /** Prevent late startup and lifecycle callbacks from republishing after shutdown. */
229
+ private shuttingDown = false;
180
230
  constructor(
181
231
  private readonly config: LspConfig,
182
232
  private readonly cwd: string,
@@ -247,11 +297,27 @@ export class LspManager {
247
297
  }
248
298
 
249
299
  /** Get or create an LSP client for the given file. */
250
- async getClientForFile(filePath: string): Promise<LspClient | null> {
300
+ async getClientForFile(
301
+ filePath: string,
302
+ options: FileClientRequestOptions = {},
303
+ ): Promise<LspClient | null> {
304
+ throwIfCodeRequestInterrupted(options.control);
305
+ if (this.shuttingDown) return null;
251
306
  const route = this.resolveFileRoute(filePath);
252
- return route ? this.startServerForRoot(route.serverName, route.root) : null;
307
+ if (!route) return null;
308
+
309
+ const recovery = this.processCrashRecoveries.get(route.key);
310
+ if (recovery && (recovery.pending || recovery.statusReason)) {
311
+ if (!options.recoverProcessCrash) return null;
312
+ return this.acquireProcessCrashReplacement(recovery, route, options.control);
313
+ }
314
+ // A recovered route keeps its consumed budget but has no active reason.
315
+ // Passive callers can use its running client normally.
316
+
317
+ return this.startServerForRoot(route.serverName, route.root);
253
318
  }
254
319
  async startServerForRoot(serverName: string, root: string): Promise<LspClient | null> {
320
+ if (this.shuttingDown) return null;
255
321
  const serverConfig = this.config.servers[serverName];
256
322
  if (!serverConfig) return null;
257
323
  const key = clientKey(serverName, root);
@@ -261,6 +327,12 @@ export class LspManager {
261
327
  const existing = this.clients.get(key);
262
328
  if (existing && existing.status === "running") return existing;
263
329
 
330
+ // A crashed route is retained until evidence demand starts its shared
331
+ // replacement. Proactive starts must not consume that budget.
332
+ if (existing && existing.status === "error" && this.processCrashRecoveries.has(key)) {
333
+ return null;
334
+ }
335
+
264
336
  // If existing client errored, remove it
265
337
  if (existing && existing.status === "error") {
266
338
  this.clients.delete(key);
@@ -315,13 +387,341 @@ export class LspManager {
315
387
  client: LspClient,
316
388
  kind: LspClientLifecycleTransitionKind,
317
389
  ): void {
390
+ if (this.shuttingDown) return;
318
391
  if (this.clientGenerations.get(key) !== generation) return;
319
392
  if (this.clients.get(key) !== client) return;
393
+
394
+ if (kind === "startup") {
395
+ this.initializedClientGenerations.set(key, generation);
396
+ }
397
+ if (kind === "crash") {
398
+ if (this.handledCrashGenerations.get(key) === generation) return;
399
+ this.handledCrashGenerations.set(key, generation);
400
+ if (this.initializedClientGenerations.get(key) === generation) {
401
+ this.handleInitializedClientCrash(key, client);
402
+ }
403
+ }
404
+
320
405
  const aggregateKind =
321
406
  kind === "readiness" && generation > 1 && client.ready ? "recovery" : kind;
322
407
  this.publishLifecycle(aggregateKind);
323
408
  }
324
409
 
410
+ private handleInitializedClientCrash(key: string, client: LspClient): void {
411
+ const existing = this.processCrashRecoveries.get(key);
412
+ if (existing?.attemptConsumed) {
413
+ existing.failedClient = client;
414
+ existing.files = this.trackedFilesForClient(client);
415
+ this.unavailable.set(key, "runtime-error");
416
+ if (existing.statusReason !== "process-crash-recovery-exhausted") {
417
+ existing.statusReason = "process-crash-recovery-exhausted";
418
+ this.recordProcessCrashRecoveryEvent(
419
+ client.name,
420
+ client.root,
421
+ "exhausted",
422
+ existing.pending ? existing.attemptStartedAt : Date.now(),
423
+ );
424
+ }
425
+ return;
426
+ }
427
+
428
+ this.processCrashRecoveries.set(key, {
429
+ attemptConsumed: false,
430
+ statusReason: "process-crashed",
431
+ failedClient: client,
432
+ files: this.trackedFilesForClient(client),
433
+ pending: null,
434
+ });
435
+ }
436
+
437
+ private trackedFilesForClient(client: LspClient): string[] {
438
+ const files = new Set(client.openFiles);
439
+ for (const document of client.getDiagnosticSnapshot().documents) {
440
+ files.add(uriToFile(document.uri));
441
+ }
442
+ return Array.from(files);
443
+ }
444
+
445
+ private waitForProcessCrashRecovery(
446
+ recovery: ProcessCrashRecoveryState,
447
+ control?: CodeRequestControl,
448
+ ): Promise<LspClient | null> {
449
+ if (!recovery.pending) return Promise.resolve(null);
450
+ return raceRequestControl(recovery.pending, control);
451
+ }
452
+
453
+ private acquireProcessCrashReplacement(
454
+ recovery: ProcessCrashRecoveryState,
455
+ route: FileRoute,
456
+ control?: CodeRequestControl,
457
+ ): Promise<LspClient | null> {
458
+ if (recovery.pending) return this.waitForProcessCrashRecovery(recovery, control);
459
+ if (
460
+ recovery.statusReason === "process-crashed" &&
461
+ !recovery.attemptConsumed &&
462
+ !this.shuttingDown
463
+ ) {
464
+ return this.startProcessCrashRecovery(recovery, route, control);
465
+ }
466
+ return Promise.resolve(null);
467
+ }
468
+
469
+ /**
470
+ * Recover all crashed routes selected by one evidence-producing operation.
471
+ * Each route keeps its independent attempt budget and shared replacement.
472
+ */
473
+ private async recoverProcessCrashDemand(
474
+ selectFiles: (recovery: ProcessCrashRecoveryState) => readonly string[] | null,
475
+ control?: CodeRequestControl,
476
+ ): Promise<ProcessCrashDemandResult> {
477
+ const required = Array.from(this.processCrashRecoveries.entries()).flatMap(
478
+ ([key, recovery]): RequiredProcessCrashDemand[] => {
479
+ if (recovery.statusReason === undefined) return [];
480
+ const failedFiles = selectFiles(recovery);
481
+ return failedFiles === null ? [] : [{ key, recovery, failedFiles }];
482
+ },
483
+ );
484
+ if (required.length === 0) return { hasSupport: false, failures: [], failedFiles: [] };
485
+
486
+ await Promise.all(
487
+ required.map(async ({ key, recovery }) => {
488
+ const serverConfig = this.config.servers[recovery.failedClient.name];
489
+ if (!serverConfig) return;
490
+ await this.acquireProcessCrashReplacement(
491
+ recovery,
492
+ {
493
+ serverName: recovery.failedClient.name,
494
+ serverConfig,
495
+ root: recovery.failedClient.root,
496
+ key,
497
+ },
498
+ control,
499
+ );
500
+ }),
501
+ );
502
+
503
+ const failedDemands = required.filter(({ recovery }) => recovery.statusReason !== undefined);
504
+ return {
505
+ hasSupport: true,
506
+ failures: failedDemands.map(({ recovery }) => this.formatProcessCrashDemandFailure(recovery)),
507
+ failedFiles: Array.from(new Set(failedDemands.flatMap(({ failedFiles }) => failedFiles))),
508
+ };
509
+ }
510
+
511
+ private formatProcessCrashDemandFailure(recovery: ProcessCrashRecoveryState): string {
512
+ const root = (path.relative(this.cwd, recovery.failedClient.root) || ".").replaceAll("\\", "/");
513
+ const reason =
514
+ recovery.statusReason === "process-crash-recovery-exhausted"
515
+ ? "process recovery exhausted; reload required"
516
+ : recovery.statusReason === "process-crash-recovery-pending"
517
+ ? "process recovery in progress"
518
+ : "process crashed";
519
+ return `${recovery.failedClient.name} @ ${root} is unavailable — ${reason}.`;
520
+ }
521
+
522
+ private startProcessCrashRecovery(
523
+ recovery: ProcessCrashRecoveryState,
524
+ route: FileRoute,
525
+ control?: CodeRequestControl,
526
+ ): Promise<LspClient | null> {
527
+ throwIfCodeRequestInterrupted(control);
528
+ recovery.attemptConsumed = true;
529
+ recovery.statusReason = "process-crash-recovery-pending";
530
+ recovery.attemptStartedAt = Date.now();
531
+ const pending = this.performProcessCrashRecovery(recovery, route);
532
+ recovery.pending = pending;
533
+ // A cancelled caller may be the only current waiter. Keep the shared
534
+ // replacement promise observed until it settles without adopting that
535
+ // caller's cancellation or deadline.
536
+ pending.catch(() => {});
537
+ this.recordProcessCrashRecoveryEvent(
538
+ route.serverName,
539
+ route.root,
540
+ "attempt",
541
+ recovery.attemptStartedAt,
542
+ );
543
+ this.publishLifecycle("recovery");
544
+ return raceRequestControl(pending, control);
545
+ }
546
+
547
+ private async performProcessCrashRecovery(
548
+ recovery: ProcessCrashRecoveryState,
549
+ route: FileRoute,
550
+ ): Promise<LspClient | null> {
551
+ const key = route.key;
552
+ const attemptStartedAt = recovery.attemptStartedAt;
553
+
554
+ try {
555
+ // A process-error event can leave the child alive. Stop the old tree
556
+ // before installing its replacement, but retain its captured evidence.
557
+ await recovery.failedClient.forceKill().catch(() => {});
558
+ if (this.isProcessCrashRecoveryCancelled(recovery, key)) {
559
+ this.recordProcessCrashRecoveryEvent(
560
+ route.serverName,
561
+ route.root,
562
+ "cancelled",
563
+ attemptStartedAt,
564
+ );
565
+ return null;
566
+ }
567
+
568
+ const replacement = this.installProcessCrashReplacement(recovery, route);
569
+ const started = await this.startProcessCrashReplacement(recovery, route, replacement);
570
+ if (!started) return null;
571
+
572
+ // A replacement can crash after initialize and before this operation
573
+ // resumes. Its lifecycle callback has already exhausted the route.
574
+ if (
575
+ started.status !== "running" ||
576
+ recovery.statusReason === "process-crash-recovery-exhausted"
577
+ ) {
578
+ return null;
579
+ }
580
+
581
+ this.unavailable.delete(key);
582
+ recovery.statusReason = undefined;
583
+ this.publishLifecycle("recovery");
584
+ this.restoreTrackedDocuments(started, recovery.files);
585
+ this.recordProcessCrashRecoveryEvent(
586
+ route.serverName,
587
+ route.root,
588
+ "success",
589
+ attemptStartedAt,
590
+ );
591
+ return started;
592
+ } finally {
593
+ if (this.processCrashRecoveries.get(key) === recovery) recovery.pending = null;
594
+ }
595
+ }
596
+
597
+ private isProcessCrashRecoveryCancelled(
598
+ recovery: ProcessCrashRecoveryState,
599
+ key: string,
600
+ ): boolean {
601
+ return this.shuttingDown || this.processCrashRecoveries.get(key) !== recovery;
602
+ }
603
+
604
+ private installProcessCrashReplacement(
605
+ recovery: ProcessCrashRecoveryState,
606
+ route: FileRoute,
607
+ ): LspClient {
608
+ const key = route.key;
609
+ if (this.clients.get(key) === recovery.failedClient) this.clients.delete(key);
610
+ this.clearWarmedWorkspaceSymbolProjects(route.serverName, route.root);
611
+ this.clearWarmedSemanticProjects(route.serverName, route.root);
612
+ this.clearPendingWarmProbes(route.serverName, route.root);
613
+
614
+ const replacement = this.createClient(route.serverName, route.serverConfig, route.root, key);
615
+ this.clients.set(key, replacement);
616
+ rememberKnownRoot(this.knownRoots, route.serverName, route.root);
617
+ return replacement;
618
+ }
619
+
620
+ private async startProcessCrashReplacement(
621
+ recovery: ProcessCrashRecoveryState,
622
+ route: FileRoute,
623
+ replacement: LspClient,
624
+ ): Promise<LspClient | null> {
625
+ try {
626
+ // LspClient's initialize request already has the 30-second transport
627
+ // bound. Do not apply the 5-second diagnostic-restart bound here.
628
+ await replacement.start();
629
+ } catch {
630
+ if (this.isProcessCrashRecoveryCancelled(recovery, route.key)) {
631
+ await replacement.forceKill().catch(() => {});
632
+ this.recordProcessCrashRecoveryEvent(
633
+ route.serverName,
634
+ route.root,
635
+ "cancelled",
636
+ recovery.attemptStartedAt,
637
+ );
638
+ return null;
639
+ }
640
+ if (recovery.statusReason !== "process-crash-recovery-exhausted") {
641
+ this.markProcessCrashRecoveryFailed(recovery, route.key, replacement);
642
+ this.recordProcessCrashRecoveryEvent(
643
+ route.serverName,
644
+ route.root,
645
+ "failure",
646
+ recovery.attemptStartedAt,
647
+ );
648
+ }
649
+ return null;
650
+ }
651
+
652
+ if (
653
+ this.isProcessCrashRecoveryCancelled(recovery, route.key) ||
654
+ this.clients.get(route.key) !== replacement
655
+ ) {
656
+ await replacement.forceKill().catch(() => {});
657
+ this.recordProcessCrashRecoveryEvent(
658
+ route.serverName,
659
+ route.root,
660
+ "cancelled",
661
+ recovery.attemptStartedAt,
662
+ );
663
+ return null;
664
+ }
665
+ return replacement;
666
+ }
667
+
668
+ /** Restore route documents from disk and retain failed-document evidence. */
669
+ private restoreTrackedDocuments(client: LspClient, files: readonly string[]): void {
670
+ const failedFiles: string[] = [];
671
+ for (const filePath of files) {
672
+ if (!fs.existsSync(filePath)) {
673
+ failedFiles.push(filePath);
674
+ continue;
675
+ }
676
+ try {
677
+ client.didOpen(filePath, fs.readFileSync(filePath, "utf-8"));
678
+ } catch {
679
+ failedFiles.push(filePath);
680
+ }
681
+ }
682
+ for (const filePath of failedFiles) client.markFailedFile(filePath);
683
+ }
684
+
685
+ private markProcessCrashRecoveryFailed(
686
+ recovery: ProcessCrashRecoveryState,
687
+ key: string,
688
+ replacement: LspClient,
689
+ ): void {
690
+ recovery.statusReason = "process-crash-recovery-exhausted";
691
+ this.unavailable.set(key, "start-failed");
692
+ if (this.clients.get(key) === replacement) this.clients.delete(key);
693
+ // A failed initialize may still leave a child process alive. The
694
+ // replacement is no longer owned by the manager before it is terminated.
695
+ void replacement.forceKill().catch(() => {});
696
+ this.publishLifecycle("recovery");
697
+ }
698
+
699
+ private recordProcessCrashRecoveryEvent(
700
+ serverName: string,
701
+ root: string,
702
+ outcome: ProcessCrashRecoveryOutcome,
703
+ startedAt?: number,
704
+ ): void {
705
+ try {
706
+ recordDebugEvent({
707
+ source: "lsp",
708
+ level: outcome === "failure" || outcome === "exhausted" ? "warning" : "debug",
709
+ category: "runtime.recovery",
710
+ message: `LSP process-crash recovery ${outcome}`,
711
+ cwd: boundCwd(this.cwd),
712
+ data: {
713
+ reason: "process-crash",
714
+ outcome,
715
+ server: truncateIdentity(serverName),
716
+ root: truncateIdentity(root),
717
+ elapsedMs: startedAt === undefined ? 0 : Math.max(0, Date.now() - startedAt),
718
+ },
719
+ });
720
+ } catch {
721
+ // Recovery telemetry must never change route behavior.
722
+ }
723
+ }
724
+
325
725
  private publishLifecycle(kind: ManagerLifecycleTransitionKind): void {
326
726
  if (!this.onLifecycleTransition) return;
327
727
  const projectServers = this.getKnownProjectServers([]);
@@ -360,11 +760,16 @@ export class LspManager {
360
760
  rememberKnownRoot(this.knownRoots, serverName, root);
361
761
  try {
362
762
  await client.start();
763
+ if (this.shuttingDown) {
764
+ await client.forceKill().catch(() => {});
765
+ if (this.clients.get(key) === client) this.clients.delete(key);
766
+ return null;
767
+ }
363
768
  this.unavailable.delete(key);
364
769
  return client;
365
770
  } catch {
366
771
  this.unavailable.set(key, "start-failed");
367
- this.clients.delete(key);
772
+ if (this.clients.get(key) === client) this.clients.delete(key);
368
773
  return null;
369
774
  }
370
775
  }
@@ -408,6 +813,8 @@ export class LspManager {
408
813
  if (options?.pushOnly && client.hasDiagnosticProvider) continue;
409
814
 
410
815
  const key = clientKey(client.name, client.root);
816
+ const processRecovery = this.processCrashRecoveries.get(key);
817
+ if (processRecovery?.pending) continue;
411
818
  if (seen.has(key)) continue;
412
819
  seen.add(key);
413
820
  if (this.recoveryRestartEpochs.get(key) === this.invalidationEpoch) continue;
@@ -455,19 +862,8 @@ export class LspManager {
455
862
  RECOVERY_CLIENT_STARTUP_BOUND_MS,
456
863
  "replacement client startup bound exceeded",
457
864
  );
458
- const failedFiles: string[] = [];
459
- for (const filePath of files) {
460
- if (!fs.existsSync(filePath)) {
461
- failedFiles.push(filePath);
462
- continue;
463
- }
464
- try {
465
- replacement.didOpen(filePath, fs.readFileSync(filePath, "utf-8"));
466
- } catch {
467
- failedFiles.push(filePath);
468
- }
469
- }
470
- for (const filePath of failedFiles) replacement.markFailedFile(filePath);
865
+ this.restoreTrackedDocuments(replacement, files);
866
+ this.clearUnconsumedProcessCrashState(key, client);
471
867
  return { files, restarted: true };
472
868
  } catch {
473
869
  this.clients.delete(key);
@@ -479,6 +875,13 @@ export class LspManager {
479
875
  }
480
876
  }
481
877
 
878
+ private clearUnconsumedProcessCrashState(key: string, failedClient: LspClient): void {
879
+ const recovery = this.processCrashRecoveries.get(key);
880
+ if (recovery?.failedClient !== failedClient || recovery.attemptConsumed) return;
881
+ this.processCrashRecoveries.delete(key);
882
+ this.publishLifecycle("recovery");
883
+ }
884
+
482
885
  getProjectServerInfo(serverName: string, root: string, fileTypes: string[]): ProjectServerInfo {
483
886
  const key = clientKey(serverName, root);
484
887
  return buildProjectServerInfo(
@@ -489,6 +892,7 @@ export class LspManager {
489
892
  client: this.clients.get(key),
490
893
  unavailableReason:
491
894
  this.getUnavailableReason(key, this.config.servers[serverName]?.command) ?? undefined,
895
+ statusReason: this.processCrashRecoveries.get(key)?.statusReason,
492
896
  },
493
897
  this.cwd,
494
898
  );
@@ -507,6 +911,19 @@ export class LspManager {
507
911
  fileTypes: [...(this.config.servers[client.name]?.fileTypes ?? [])],
508
912
  });
509
913
  }
914
+ // Keep an exhausted recovery route visible after its failed replacement
915
+ // is removed from the active client pool.
916
+ for (const recovery of this.processCrashRecoveries.values()) {
917
+ const name = recovery.failedClient.name;
918
+ const root = recovery.failedClient.root;
919
+ const key = clientKey(name, root);
920
+ if (known.has(key)) continue;
921
+ known.set(key, {
922
+ name,
923
+ root,
924
+ fileTypes: [...(this.config.servers[name]?.fileTypes ?? [])],
925
+ });
926
+ }
510
927
  return Array.from(known.values())
511
928
  .map((entry) => this.getProjectServerInfo(entry.name, entry.root, entry.fileTypes))
512
929
  .sort(
@@ -524,7 +941,10 @@ export class LspManager {
524
941
  ): Promise<LspClient | null> {
525
942
  throwIfCodeRequestInterrupted(control);
526
943
  const resolvedPath = resolveSessionPath(this.cwd, filePath);
527
- const client = await this.getClientForFile(resolvedPath);
944
+ const client = await this.getClientForFile(resolvedPath, {
945
+ recoverProcessCrash: true,
946
+ control,
947
+ });
528
948
  if (!client) return null;
529
949
  // The caller's wait stops on cancellation even though the shared
530
950
  // readiness state keeps its own lifecycle.
@@ -582,7 +1002,10 @@ export class LspManager {
582
1002
  control?: CodeRequestControl,
583
1003
  ): Promise<CodeQueryResult<Diagnostic[]>> {
584
1004
  const resolvedPath = resolveSessionPath(this.cwd, filePath);
585
- const client = await this.getClientForFile(resolvedPath);
1005
+ const client = await this.getClientForFile(resolvedPath, {
1006
+ recoverProcessCrash: true,
1007
+ control,
1008
+ });
586
1009
  if (!client) {
587
1010
  return unavailableCodeQuery(`No LSP client can collect diagnostics for ${resolvedPath}.`);
588
1011
  }
@@ -678,20 +1101,83 @@ export class LspManager {
678
1101
  quietMs?: number;
679
1102
  /** Evidence from a refresh the caller already completed; skips this pass's own refresh when no watched-file changes apply. */
680
1103
  initialEvidence?: DiagnosticEvidenceSummary;
1104
+ /** Explicit demand to recover crashed routes with tracked files in scope. */
1105
+ processCrashDemand?: { scopes?: readonly string[] };
681
1106
  control?: CodeRequestControl;
682
1107
  }): Promise<WorkspaceRecoveryResult> {
683
- return recoverWorkspaceDiagnosticsImpl(this, options);
1108
+ const demand = options?.processCrashDemand
1109
+ ? await this.recoverProcessCrashDemand((recovery) => {
1110
+ const files = this.processCrashDiagnosticFiles(recovery, options.processCrashDemand);
1111
+ return files.length > 0 ? files : null;
1112
+ }, options.control)
1113
+ : { hasSupport: false, failures: [], failedFiles: [] };
1114
+ const recoveryOptions = demand.hasSupport
1115
+ ? { ...options, initialEvidence: undefined }
1116
+ : options;
1117
+ const result = await recoverWorkspaceDiagnosticsImpl(this, recoveryOptions);
1118
+ return this.addProcessCrashDiagnosticEvidence(result, demand);
1119
+ }
1120
+
1121
+ private processCrashDiagnosticFiles(
1122
+ recovery: ProcessCrashRecoveryState,
1123
+ demand: { scopes?: readonly string[] } | undefined,
1124
+ ): string[] {
1125
+ if (!demand) return [];
1126
+ const scopes = demand.scopes?.map((scope) => resolveSessionPath(this.cwd, scope));
1127
+ return recovery.files.filter(
1128
+ (file) =>
1129
+ this.isDiagnosticFile(file) &&
1130
+ (!scopes ||
1131
+ scopes.length === 0 ||
1132
+ scopes.some((scope) => projectRoots.isWithinOrEqual(scope, file))),
1133
+ );
1134
+ }
1135
+
1136
+ private addProcessCrashDiagnosticEvidence(
1137
+ result: WorkspaceRecoveryResult,
1138
+ demand: ProcessCrashDemandResult,
1139
+ ): WorkspaceRecoveryResult {
1140
+ if (demand.failures.length === 0) return result;
1141
+ const byFile = new Map(
1142
+ result.diagnosticEvidence.documents.map((document) => [document.file, document] as const),
1143
+ );
1144
+ for (const file of demand.failedFiles) {
1145
+ const relativeFile = path.relative(this.cwd, file);
1146
+ byFile.set(relativeFile, { file: relativeFile, status: "failed" });
1147
+ }
1148
+ const diagnosticEvidence = summarizeDiagnosticEvidence(Array.from(byFile.values()));
1149
+ const diagnosticReport = {
1150
+ summary: { ...result.diagnosticReport.summary, current: false, evidence: diagnosticEvidence },
1151
+ outstanding: {
1152
+ ...result.diagnosticReport.outstanding,
1153
+ current: false,
1154
+ evidence: diagnosticEvidence,
1155
+ },
1156
+ };
1157
+ return {
1158
+ ...result,
1159
+ diagnosticEvidence,
1160
+ diagnosticReport,
1161
+ refreshFailureReason: [result.refreshFailureReason, ...demand.failures]
1162
+ .filter(Boolean)
1163
+ .join("; "),
1164
+ };
684
1165
  }
685
1166
 
686
1167
  /** Shut down all running LSP servers. */
687
1168
  async shutdownAll(): Promise<void> {
688
- const shutdowns = Array.from(this.clients.values()).map((c) => c.shutdown().catch(() => {}));
1169
+ this.shuttingDown = true;
1170
+ const clients = Array.from(this.clients.values());
1171
+ const shutdowns = clients.map((client) => client.shutdown().catch(() => {}));
689
1172
  await Promise.all(shutdowns);
690
1173
  // Dispose every client so pending or active progress tokens and
691
1174
  // readiness promises cannot outlive the manager.
692
- for (const client of this.clients.values()) client.dispose();
1175
+ for (const client of clients) client.dispose();
693
1176
  this.clients.clear();
694
1177
  this.clientGenerations.clear();
1178
+ this.initializedClientGenerations.clear();
1179
+ this.handledCrashGenerations.clear();
1180
+ this.processCrashRecoveries.clear();
695
1181
  this.recoveryRestartEpochs.clear();
696
1182
  this.unavailable.clear();
697
1183
  this.knownRoots.clear();
@@ -1020,8 +1506,34 @@ export class LspManager {
1020
1506
  async workspaceSymbol(
1021
1507
  query: string,
1022
1508
  control?: CodeRequestControl,
1509
+ scopes?: readonly string[],
1023
1510
  ): Promise<CodeQueryResult<(SymbolInformation | WorkspaceSymbol)[]>> {
1024
- const initial = await collectWorkspaceSymbols(this.clients.values(), query, control);
1511
+ const resolvedScopes = scopes?.map((scope) => resolveSessionPath(this.cwd, scope));
1512
+ const routeMatches = (client: LspClient) =>
1513
+ this.workspaceSymbolRouteMatchesScopes(client, resolvedScopes);
1514
+ if (resolvedScopes && !this.hasWorkspaceSymbolRouteForScopes(resolvedScopes)) {
1515
+ return unavailableCodeQuery(
1516
+ "No known LSP route intersects the requested workspace-symbol scope.",
1517
+ );
1518
+ }
1519
+ const demand = await this.recoverProcessCrashDemand(
1520
+ (recovery) =>
1521
+ recovery.failedClient.serverCapabilities?.workspaceSymbolProvider &&
1522
+ routeMatches(recovery.failedClient)
1523
+ ? []
1524
+ : null,
1525
+ control,
1526
+ );
1527
+ const collect = async (clients: Iterable<LspClient>, value: string) =>
1528
+ this.addWorkspaceSymbolDemandEvidence(
1529
+ await collectWorkspaceSymbols(
1530
+ Array.from(clients).filter((client) => routeMatches(client)),
1531
+ value,
1532
+ control,
1533
+ ),
1534
+ demand,
1535
+ );
1536
+ const initial = await collect(this.clients.values(), query);
1025
1537
  if (!initial.hasSupport || initial.results.length > 0) {
1026
1538
  return workspaceSymbolCollectionResult(initial);
1027
1539
  }
@@ -1029,15 +1541,57 @@ export class LspManager {
1029
1541
  const warmed = await this.warmWorkspaceSymbolProjectsUntilResult({
1030
1542
  findWarmTargets: findWorkspaceSymbolWarmTargets,
1031
1543
  getWarmPosition: getWorkspaceSymbolWarmPosition,
1032
- collect: (clients, value) => collectWorkspaceSymbols(clients, value, control),
1544
+ collect,
1545
+ routeMatches,
1033
1546
  query,
1034
1547
  control,
1035
1548
  });
1036
1549
  return workspaceSymbolCollectionResult(warmed.collection ?? initial);
1037
1550
  }
1038
- async ensureFileOpen(filePath: string): Promise<LspClient | null> {
1551
+
1552
+ private addWorkspaceSymbolDemandEvidence(
1553
+ collection: WorkspaceSymbolCollection,
1554
+ demand: ProcessCrashDemandResult,
1555
+ ): WorkspaceSymbolCollection {
1556
+ return {
1557
+ ...collection,
1558
+ hasSupport: collection.hasSupport || demand.hasSupport,
1559
+ failures: [...collection.failures, ...demand.failures],
1560
+ };
1561
+ }
1562
+
1563
+ private hasWorkspaceSymbolRouteForScopes(scopes: readonly string[]): boolean {
1564
+ const clients = [
1565
+ ...this.clients.values(),
1566
+ ...Array.from(this.processCrashRecoveries.values(), (recovery) => recovery.failedClient),
1567
+ ];
1568
+ return clients.some((client) => this.workspaceSymbolRouteMatchesScopes(client, scopes));
1569
+ }
1570
+
1571
+ private workspaceSymbolRouteMatchesScopes(
1572
+ client: LspClient,
1573
+ scopes: readonly string[] | undefined,
1574
+ ): boolean {
1575
+ if (!scopes || scopes.length === 0) return true;
1576
+ const key = clientKey(client.name, client.root);
1577
+ return scopes.some((scope) => {
1578
+ try {
1579
+ if (fs.statSync(scope).isFile()) return this.resolveFileRoute(scope)?.key === key;
1580
+ } catch {
1581
+ return false;
1582
+ }
1583
+ return (
1584
+ projectRoots.isWithinOrEqual(client.root, scope) ||
1585
+ projectRoots.isWithinOrEqual(scope, client.root)
1586
+ );
1587
+ });
1588
+ }
1589
+ async ensureFileOpen(
1590
+ filePath: string,
1591
+ options: FileClientRequestOptions = {},
1592
+ ): Promise<LspClient | null> {
1039
1593
  const resolvedPath = resolveSessionPath(this.cwd, filePath);
1040
- const client = await this.getClientForFile(resolvedPath);
1594
+ const client = await this.getClientForFile(resolvedPath, options);
1041
1595
  if (!client) return null;
1042
1596
  try {
1043
1597
  client.didOpen(resolvedPath, fs.readFileSync(resolvedPath, "utf-8"));
@@ -1059,13 +1613,15 @@ export class LspManager {
1059
1613
  symbols: import("../config/types.ts").DocumentSymbol[] | SymbolInformation[] | null,
1060
1614
  ) => import("../config/types.ts").Position | null;
1061
1615
  collect: (clients: Iterable<LspClient>, query: string) => Promise<WorkspaceSymbolCollection>;
1616
+ routeMatches?: (client: LspClient) => boolean;
1062
1617
  query: string;
1063
1618
  control?: CodeRequestControl;
1064
1619
  }): Promise<{ warmedAny: boolean; collection: WorkspaceSymbolCollection | null }> {
1065
- const { findWarmTargets, getWarmPosition, collect, query, control } = options;
1620
+ const { findWarmTargets, getWarmPosition, collect, routeMatches, query, control } = options;
1066
1621
  let warmedAny = false;
1067
1622
 
1068
1623
  for (const client of Array.from(this.clients.values())) {
1624
+ if (routeMatches && !routeMatches(client)) continue;
1069
1625
  if (client.status !== "running") continue;
1070
1626
  if (!client.serverCapabilities?.workspaceSymbolProvider) continue;
1071
1627