pi-lens 3.8.71 → 3.8.73

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 (83) hide show
  1. package/CHANGELOG.md +152 -1
  2. package/README.md +31 -22
  3. package/config/markdownlint/core.json +6 -0
  4. package/dist/clients/atomic-write.js +83 -0
  5. package/dist/clients/config-enabled-shape.js +39 -0
  6. package/dist/clients/deps/js-yaml.js +1 -1
  7. package/dist/clients/diagnostic-dispositions.js +64 -2
  8. package/dist/clients/dispatch/integration.js +2 -1
  9. package/dist/clients/dispatch/runners/ast-grep-napi.js +60 -7
  10. package/dist/clients/dispatch/runners/lsp.js +54 -25
  11. package/dist/clients/dispatch/runners/markdownlint.js +7 -4
  12. package/dist/clients/dispatch/runners/pyright.js +1 -0
  13. package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
  14. package/dist/clients/env-utils.js +38 -0
  15. package/dist/clients/file-utils.js +21 -3
  16. package/dist/clients/instance-registry.js +32 -21
  17. package/dist/clients/jscpd-client.js +52 -39
  18. package/dist/clients/language-profile.js +12 -19
  19. package/dist/clients/lens-config.js +106 -27
  20. package/dist/clients/lens-engine.js +129 -3
  21. package/dist/clients/lsp/cascade-tier.js +1 -38
  22. package/dist/clients/lsp/client.js +3 -1
  23. package/dist/clients/lsp/index.js +707 -109
  24. package/dist/clients/lsp/server-strategies.js +3 -224
  25. package/dist/clients/lsp/server.js +1 -1
  26. package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
  27. package/dist/clients/lsp/wait-policy/classification.js +63 -0
  28. package/dist/clients/lsp/wait-policy/index.js +10 -0
  29. package/dist/clients/lsp/wait-policy/strategies.js +244 -0
  30. package/dist/clients/lsp-budget.js +54 -16
  31. package/dist/clients/mcp/analyze.js +1 -1
  32. package/dist/clients/mcp/host-shim.js +12 -8
  33. package/dist/clients/mcp/ipc.js +118 -0
  34. package/dist/clients/mcp/session.js +2 -2
  35. package/dist/clients/pipeline.js +11 -6
  36. package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
  37. package/dist/clients/project-diagnostics/scanner.js +37 -6
  38. package/dist/clients/project-lens-config.js +167 -3
  39. package/dist/clients/project-report.js +627 -0
  40. package/dist/clients/project-scale.js +239 -0
  41. package/dist/clients/project-scan-policy.js +11 -1
  42. package/dist/clients/recent-touches.js +6 -11
  43. package/dist/clients/review-graph/builder.js +170 -13
  44. package/dist/clients/review-graph/import-resolvers.js +149 -13
  45. package/dist/clients/review-graph/tsconfig-paths.js +242 -0
  46. package/dist/clients/review-graph/workspace-modules.js +21 -9
  47. package/dist/clients/rules-scanner.js +15 -2
  48. package/dist/clients/runtime-agent-end.js +104 -17
  49. package/dist/clients/runtime-config.js +4 -0
  50. package/dist/clients/runtime-coordinator.js +50 -1
  51. package/dist/clients/runtime-session.js +41 -3
  52. package/dist/clients/runtime-tool-result.js +4 -3
  53. package/dist/clients/runtime-turn.js +4 -1
  54. package/dist/clients/safe-spawn.js +264 -40
  55. package/dist/clients/session-lifecycle.js +36 -0
  56. package/dist/clients/session-state-store.js +8 -3
  57. package/dist/clients/source-filter.js +136 -61
  58. package/dist/clients/source-walker.js +89 -0
  59. package/dist/clients/startup-scan.js +189 -111
  60. package/dist/clients/subagent-mode.js +14 -3
  61. package/dist/clients/todo-scanner.js +5 -1
  62. package/dist/clients/tool-policy.js +26 -4
  63. package/dist/clients/warm-attach.js +204 -0
  64. package/dist/clients/word-index.js +36 -5
  65. package/dist/clients/workspace-topology.js +266 -0
  66. package/dist/index.js +15205 -11330
  67. package/dist/mcp/server.js +125 -10
  68. package/dist/tools/ast-grep-search.js +21 -0
  69. package/dist/tools/lens-diagnostic-mark.js +198 -7
  70. package/dist/tools/lens-diagnostics.js +119 -21
  71. package/dist/tools/lsp-diagnostics.js +143 -10
  72. package/dist/tools/lsp-navigation.js +11 -2
  73. package/dist/tools/project-report.js +94 -0
  74. package/dist/tools/symbol-search.js +32 -8
  75. package/docs/environment-variables.md +10 -0
  76. package/docs/features.md +7 -1
  77. package/docs/globalconfig.md +70 -0
  78. package/docs/servercapabilities.md +1 -1
  79. package/docs/usage.md +13 -2
  80. package/package.json +8 -9
  81. package/rules/tree-sitter-queries/typescript/no-equality-in-for-condition.yml +1 -0
  82. package/rules/tree-sitter-queries/typescript/no-jump-in-finally.yml +1 -0
  83. package/scripts/download-grammars.js +1 -0
@@ -17,18 +17,19 @@ import { recordLsp } from "../widget-state.js";
17
17
  import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
18
18
  import { detectFileRole } from "../file-role.js";
19
19
  import { logLatency } from "../latency-logger.js";
20
+ import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
20
21
  import { withDeadline } from "../deadline-utils.js";
21
- import { normalizeMapKey, uriToPath } from "../path-utils.js";
22
+ import { isAtOrAboveHomeDir, normalizeMapKey, uriToPath, } from "../path-utils.js";
22
23
  import { createLSPClient } from "./client.js";
23
24
  import { getServersForFileWithConfig, getServerInitOverride } from "./config.js";
24
25
  import { getLanguageId } from "./language.js";
25
26
  import { LSP_SERVERS, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
26
- import { getStrategy } from "./server-strategies.js";
27
+ import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
27
28
  import { raceToCompletion } from "./aggregation.js";
28
29
  import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, } from "./edits.js";
29
30
  import { buildScopeKey, createWorkspaceDiagnosticsCacheContext, } from "./workspace-diagnostics-cache.js";
30
31
  import { attemptTsserverSyncDiagnostics, } from "./tsserver-sync.js";
31
- import { classifyCascadeWaitTier } from "./cascade-tier.js";
32
+ import { isWarmAttached, tryWarmAttachedDiagnostics, } from "../warm-attach.js";
32
33
  // --- Init override helpers ---
33
34
  /**
34
35
  * Recursively merges `override` onto `base`. Override wins on leaf conflicts
@@ -68,6 +69,12 @@ export function mergeInitializationOptions(base, override) {
68
69
  const BROKEN_BASE_COOLDOWN_MS = 15_000;
69
70
  const BROKEN_MAX_COOLDOWN_MS = 5 * 60_000; // cap at 5 minutes
70
71
  const BROKEN_PERMANENT_AFTER = 5; // disable for session after N consecutive failures
72
+ // #743: a server whose per-server notify write (didOpen/didChange) times out
73
+ // this many times in a row is a persistently backpressured server; it is demoted
74
+ // into the `broken` cooldown map (evicted + cooled down) so subsequent sweeps
75
+ // stop re-paying its notify budget on every file. A single successful write
76
+ // resets the streak.
77
+ const NOTIFY_BACKPRESSURE_BROKEN_AFTER = 3;
71
78
  const OPTIONAL_LSP_RETRY_COOLDOWN_MS = 5 * 60_000;
72
79
  const OPTIONAL_LSP_SERVER_IDS = new Set();
73
80
  const NAV_CLIENT_WAIT_TIMEOUT_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_NAV_CLIENT_WAIT_MS ?? "1500", 10) ||
@@ -85,6 +92,17 @@ function warmupTimeoutMs() {
85
92
  const raw = Number.parseInt(process.env.PI_LENS_LSP_WARMUP_TIMEOUT_MS ?? "", 10);
86
93
  return Number.isFinite(raw) && raw > 0 ? raw : 20_000;
87
94
  }
95
+ // #744: short pause between the first warm-up round trip and the single retry
96
+ // `ensureWarmForSweep` gives a server that didn't warm on its first attempt.
97
+ // Deliberately small — it's a breather for a server mid-relaunch/index (the
98
+ // state where warmup failure is most likely, e.g. a sweep starting seconds
99
+ // after an `lsp_service_reset`), not a second full budget. Read at call time so
100
+ // tests can drive the retry without waiting out a real backoff. 0 disables the
101
+ // pause entirely (retry fires immediately).
102
+ function warmupRetryBackoffMs() {
103
+ const raw = Number.parseInt(process.env.PI_LENS_LSP_WARMUP_RETRY_BACKOFF_MS ?? "", 10);
104
+ return Number.isFinite(raw) && raw >= 0 ? raw : 500;
105
+ }
88
106
  /**
89
107
  * Read the `PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS` env override at call time
90
108
  * (process.env mutations in tests stay live). Returns undefined when unset,
@@ -242,15 +260,14 @@ export async function runPerServerGroups(groups, concurrency, processGroup, sign
242
260
  let nextGroup = 0;
243
261
  const workers = Math.min(Math.max(1, concurrency), groups.length);
244
262
  await Promise.all(Array.from({ length: workers }, async () => {
245
- while (true) {
246
- if (signal?.aborted)
247
- return;
263
+ while (!signal?.aborted) {
248
264
  const gi = nextGroup;
249
265
  nextGroup += 1;
250
266
  if (gi >= groups.length)
251
- return;
267
+ break;
252
268
  await processGroup(groups[gi]);
253
269
  }
270
+ return true;
254
271
  }));
255
272
  }
256
273
  const WORKSPACE_DIAGNOSTICS_CONCURRENCY = 8;
@@ -332,8 +349,17 @@ function getMaxWorkspaceDiagnosticFiles() {
332
349
  * dropped user `"ignore": [...]` patterns and diverged from the canonical list
333
350
  * (#243). The walk is also hard-capped (#250).
334
351
  */
335
- async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceDiagnosticFiles(), signal) {
352
+ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceDiagnosticFiles(), signal, homeDir) {
336
353
  const files = [];
354
+ // #747/#250: the 5000-file cap alone bounds total work, but from a cwd at or
355
+ // above $HOME the walk still traverses (and pulls diagnostics for) 5000 files
356
+ // spread across every unrelated repo under home. Refuse outright — walking
357
+ // nothing is the honest result; the caller (runWorkspaceDiagnostics →
358
+ // tools/lens-diagnostics.ts) renders "unsafe root" so an empty sweep never
359
+ // reads as a clean project. Same ceiling as fresh-fetch.ts / the cheap-tier
360
+ // scanner.
361
+ if (isAtOrAboveHomeDir(root, homeDir))
362
+ return files;
337
363
  const ignoreMatcher = getProjectIgnoreMatcher(root);
338
364
  // #703: prime the tracked-files set once before the walk so a tracked file
339
365
  // matching a `.gitignore`/global pattern still gets its workspace
@@ -403,9 +429,27 @@ export class LSPService {
403
429
  lastKnownContentHash = new Map();
404
430
  lastDiagnosticsHealth = new Map();
405
431
  recentTouches = new Map();
432
+ /**
433
+ * #743: consecutive per-server notify-write timeout count, keyed by
434
+ * "serverId:normalizedRoot" — the SAME identity as {@link LSPState.broken}
435
+ * and {@link demonstratedReadyKeyFor}. A server that backpressures its stdin
436
+ * write {@link NOTIFY_BACKPRESSURE_BROKEN_AFTER} times in a row is demoted
437
+ * into the broken cooldown (see {@link recordNotifyWriteBackpressure}); any
438
+ * successful write clears its entry.
439
+ */
440
+ notifyWriteBackpressureStreak = new Map();
406
441
  /** True after shutdown() has been called; blocks new operations */
407
442
  isDestroyed = false;
408
- constructor() {
443
+ /**
444
+ * #850: teardown completion for every singleton generation retired before
445
+ * this service was published. Only replacement services receive one; direct
446
+ * `new LSPService()` callers and the first singleton generation stay hot-path
447
+ * identical. Cleared after the first completed wait so warm reuse never pays
448
+ * a permanent promise/microtask tax.
449
+ */
450
+ generationHandoff;
451
+ constructor(generationHandoff) {
452
+ this.generationHandoff = generationHandoff;
409
453
  this.state = {
410
454
  clients: new Map(),
411
455
  servers: new Map(),
@@ -413,6 +457,7 @@ export class LSPService {
413
457
  inFlight: new Map(),
414
458
  clientSpawnedAt: new Map(),
415
459
  demonstratedReady: new Set(),
460
+ demonstratedCold: new Set(),
416
461
  };
417
462
  }
418
463
  /** Guard: return true if service is shutting down or shut down */
@@ -498,6 +543,46 @@ export class LSPService {
498
543
  }
499
544
  markDemonstratedReadyKey(key) {
500
545
  this.state.demonstratedReady.add(key);
546
+ // #799: readiness through ANY path supersedes an earlier cold verdict —
547
+ // a server that recovers later in the session must not stay stuck in
548
+ // the negative cache.
549
+ this.state.demonstratedCold.delete(key);
550
+ }
551
+ /**
552
+ * #743: record one notify-write timeout for a server and, once it has stalled
553
+ * {@link NOTIFY_BACKPRESSURE_BROKEN_AFTER} times in a row, demote it through
554
+ * the EXISTING broken-cooldown map so subsequent sweeps stop re-paying its
555
+ * notify budget on every file. The wedged client is also evicted: an alive
556
+ * client is reused before the broken check in {@link ensureClientForServer},
557
+ * so the cooldown only bites once the stale client is gone. `key` is
558
+ * "serverId:normalizedRoot" (the broken-map identity); undefined when the
559
+ * server's root could not be resolved, in which case there is nothing to key.
560
+ */
561
+ recordNotifyWriteBackpressure(key, entry, filePath) {
562
+ if (!key)
563
+ return;
564
+ const streak = (this.notifyWriteBackpressureStreak.get(key) ?? 0) + 1;
565
+ if (streak < NOTIFY_BACKPRESSURE_BROKEN_AFTER) {
566
+ this.notifyWriteBackpressureStreak.set(key, streak);
567
+ return;
568
+ }
569
+ this.notifyWriteBackpressureStreak.delete(key);
570
+ this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
571
+ void entry.client.shutdown().catch(() => { });
572
+ this.state.clients.delete(key);
573
+ this.state.clientSpawnedAt.delete(key);
574
+ this.state.demonstratedReady.delete(key);
575
+ logLatency({
576
+ type: "phase",
577
+ phase: "lsp_notify_backpressure_broken",
578
+ filePath: normalizeMapKey(filePath),
579
+ durationMs: 0,
580
+ metadata: {
581
+ serverId: entry.info.id,
582
+ cooldownMs: BROKEN_BASE_COOLDOWN_MS,
583
+ consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
584
+ },
585
+ });
501
586
  }
502
587
  activeClientsForCwd(cwd, priorityServerIds = []) {
503
588
  const normalizedCwd = normalizeMapKey(cwd);
@@ -662,9 +747,38 @@ export class LSPService {
662
747
  }
663
748
  return undefined;
664
749
  }
750
+ /**
751
+ * Read-only liveness check for one server/file pair. Unlike
752
+ * `getClientForFile`, this never creates or warms a client; it only resolves
753
+ * the server's root and checks the already-connected client map.
754
+ */
755
+ async isServerAliveForFile(serverId, filePath) {
756
+ if (this.checkDestroyed())
757
+ return false;
758
+ for (const server of getServersForFileWithConfig(filePath)) {
759
+ if (server.id !== serverId)
760
+ continue;
761
+ const root = await server.root(filePath);
762
+ if (!root)
763
+ continue;
764
+ const key = `${server.id}:${normalizeMapKey(root)}`;
765
+ if (this.state.clients.get(key)?.isAlive())
766
+ return true;
767
+ }
768
+ return false;
769
+ }
665
770
  async ensureClientForServer(filePath, server) {
771
+ const handoff = this.generationHandoff;
772
+ if (handoff) {
773
+ await handoff;
774
+ if (this.generationHandoff === handoff) {
775
+ this.generationHandoff = undefined;
776
+ }
777
+ if (this.checkDestroyed())
778
+ return undefined;
779
+ }
666
780
  const root = await server.root(filePath);
667
- if (!root)
781
+ if (!root || this.checkDestroyed())
668
782
  return undefined;
669
783
  const allowInstall = this.shouldAllowInstall(filePath, root);
670
784
  const normalizedRoot = normalizeMapKey(root);
@@ -755,6 +869,11 @@ export class LSPService {
755
869
  if (inFlight) {
756
870
  return inFlight;
757
871
  }
872
+ // `server.root()` and a dead client's shutdown above are both async. A
873
+ // reset during either gap may have completed without seeing this request in
874
+ // state.inFlight; never let that retired generation start a late spawn.
875
+ if (this.checkDestroyed())
876
+ return undefined;
758
877
  const spawnPromise = this.spawnClient(server, root, key, filePath, allowInstall);
759
878
  this.state.inFlight.set(key, spawnPromise);
760
879
  try {
@@ -986,23 +1105,70 @@ export class LSPService {
986
1105
  // runner touch sequence expensive on slow TS projects.
987
1106
  const notifySkipped = this.shouldSkipNotify(filePath, content, clientScope);
988
1107
  const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
989
- let notifyWriteTimedOut = false;
1108
+ // #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
1109
+ // write gets its OWN notifyWriteBudgetMs budget rather than one shared
1110
+ // deadline over a single Promise.all — otherwise one backpressured server
1111
+ // (stalled stdin) times out the write for the ENTIRE file, flipping every
1112
+ // co-touched healthy server to inconclusive and zeroing its diagnostics.
1113
+ // Bounded so a backpressured write can't hang the caller; on timeout we
1114
+ // proceed — the diagnostics wait below is separately bounded and simply
1115
+ // returns no fresh diagnostics for the server(s) that stalled.
1116
+ //
1117
+ // Holds the serverId of every server whose write did NOT land in time.
1118
+ // The file-level `notifyWriteTimedOut` (logged below) means "at least one
1119
+ // server timed out"; this list carries the per-server detail the
1120
+ // demonstratedReady gate reads so a healthy sibling stays eligible.
1121
+ const notifyWriteTimedOutServerIds = [];
990
1122
  if (!notifySkipped) {
991
- // Bounded so a backpressured write can't hang the caller (see
992
- // notifyWriteBudgetMs). On timeout we proceed: the diagnostics wait below
993
- // is separately bounded and simply returns no fresh diagnostics.
994
- const wrote = await withDeadline(Promise.all(spawned.map((entry) => entry.client.notify.open(filePath, content, languageId, undefined, silent))), { ms: notifyWriteBudgetMs(), onTimeout: "undefined", onReject: "undefined" });
995
- if (wrote === undefined) {
996
- notifyWriteTimedOut = true;
1123
+ const budget = notifyWriteBudgetMs();
1124
+ await Promise.all(spawned.map(async (entry) => {
1125
+ // Same identity as the broken/demonstratedReady maps.
1126
+ const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
1127
+ let wrote;
1128
+ let rejected = false;
1129
+ try {
1130
+ wrote = await withDeadline(entry.client.notify
1131
+ .open(filePath, content, languageId, undefined, silent)
1132
+ .then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
1133
+ }
1134
+ catch {
1135
+ // The write itself rejected (not backpressure): the content did
1136
+ // not land, so this server is inconclusive for the touch, but a
1137
+ // rejection is not a stdin-backpressure signal and must not count
1138
+ // toward the backpressure demotion streak.
1139
+ rejected = true;
1140
+ }
1141
+ if (wrote === true) {
1142
+ // A clean write clears any accrued backpressure streak (#743).
1143
+ if (clientKey)
1144
+ this.notifyWriteBackpressureStreak.delete(clientKey);
1145
+ }
1146
+ else {
1147
+ notifyWriteTimedOutServerIds.push(entry.info.id);
1148
+ if (!rejected) {
1149
+ this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
1150
+ }
1151
+ }
1152
+ return true;
1153
+ }));
1154
+ if (notifyWriteTimedOutServerIds.length > 0) {
997
1155
  logLatency({
998
1156
  type: "phase",
999
1157
  phase: "lsp_notify_timeout",
1000
1158
  filePath: normalizedPath,
1001
1159
  durationMs: Date.now() - startedAt,
1002
- metadata: { source, clientScope, serverCount: spawned.length },
1160
+ metadata: {
1161
+ source,
1162
+ clientScope,
1163
+ serverCount: spawned.length,
1164
+ timedOutServerIds: notifyWriteTimedOutServerIds,
1165
+ },
1003
1166
  });
1004
1167
  }
1005
1168
  }
1169
+ // File-level flag: at least one server's write timed out (kept for the
1170
+ // conservative touch-wide `inconclusive` merge semantics — see below).
1171
+ const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
1006
1172
  let diagnosticsTimedOut = false;
1007
1173
  // R8 (#714): server ids of aux-role servers whose push wait was cut off by
1008
1174
  // the aux grace window. Undefined when no aux was cut off (primary-only
@@ -1014,7 +1180,7 @@ export class LSPService {
1014
1180
  // (undefined = the race didn't produce an answer; the end-of-wait
1015
1181
  // fallback below may still fill it in on a timed-out empty result).
1016
1182
  let tsserverSyncEligible = false;
1017
- let tsserverSyncConfirmed = undefined;
1183
+ let tsserverSyncConfirmed;
1018
1184
  if (diagnosticsMode !== "none") {
1019
1185
  // Resolution: env wins so users can tune the cap without rebuilding.
1020
1186
  // Otherwise, on the single-server hot path (primary scope), use that
@@ -1061,6 +1227,44 @@ export class LSPService {
1061
1227
  }
1062
1228
  }
1063
1229
  }
1230
+ // #832: workspace-indexing servers that are classified as silent on
1231
+ // clean do not benefit from the generic cold-indexing floor. Their
1232
+ // configured strategy already gives the first sweep touch a bounded
1233
+ // workspace-index budget (marksman: 1500ms), while the capability
1234
+ // classification proves that a clean push has no affirmative signal to
1235
+ // wait for. Keep this restricted to the workspace-indexing strategy:
1236
+ // TypeScript is also a silent-on-clean push server, but its cold project
1237
+ // load still needs the longer 20s floor.
1238
+ //
1239
+ // Build this from the live spawned client's capabilities rather than
1240
+ // server id alone. Missing/throwing capability data fails closed, so a
1241
+ // new or ambiguous server keeps the existing generous warm-up budget.
1242
+ const silentCleanWarmupServers = new Set();
1243
+ if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
1244
+ for (const entry of spawned) {
1245
+ const strategy = getStrategy(entry.client.serverId);
1246
+ if (strategy.workspaceIndexing !== true)
1247
+ continue;
1248
+ try {
1249
+ const snapshot = {
1250
+ serverId: entry.client.serverId,
1251
+ root: entry.client.root,
1252
+ operationSupport: entry.client.getOperationSupport(),
1253
+ workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
1254
+ advertisedCommands: entry.client.getAdvertisedCommands(),
1255
+ rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
1256
+ launchVariant: entry.client.getLaunchVariant?.(),
1257
+ };
1258
+ if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
1259
+ "tier3-silent") {
1260
+ silentCleanWarmupServers.add(entry.client.serverId);
1261
+ }
1262
+ }
1263
+ catch {
1264
+ // Fail closed: capability uncertainty must retain the cold floor.
1265
+ }
1266
+ }
1267
+ }
1064
1268
  // Each server gets its OWN deadline, bounded by the caller cap as a
1065
1269
  // CEILING (never a floor) — so a clean push-silent primary (typescript
1066
1270
  // ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
@@ -1088,6 +1292,18 @@ export class LSPService {
1088
1292
  // if the strategy already wants more) rather than the normal
1089
1293
  // ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
1090
1294
  if (options.warmupOverride) {
1295
+ // #832: a workspace-indexing server already classified as
1296
+ // silent-on-clean uses its strategy's bounded wait on the first
1297
+ // attempt; the generic cold floor is for servers whose cold work
1298
+ // can eventually produce a push answer (notably TypeScript).
1299
+ if (silentCleanWarmupServers.has(serverId)) {
1300
+ return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1301
+ }
1302
+ // #799: only the FIRST warm-up attempt for a cold server gets the
1303
+ // floor — see the `warmupAttempt` doc on `LSPTouchFileOptions`.
1304
+ if ((options.warmupAttempt ?? 1) > 1) {
1305
+ return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1306
+ }
1091
1307
  return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
1092
1308
  }
1093
1309
  return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
@@ -1114,16 +1330,21 @@ export class LSPService {
1114
1330
  const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
1115
1331
  // #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
1116
1332
  // starts. Cheap synchronous gates first (notify succeeded, collecting,
1117
- // primary scope, strategy marked silentOnClean — only classic
1118
- // typescript-language-server carries that flag today), then the live
1119
- // capability-snapshot tier classification (`classifyCascadeWaitTier`,
1120
- // which also excludes native-ts7 via `launchVariant`). Non-typescript
1121
- // and pull-capable servers fail the synchronous `silentOnClean` gate and
1122
- // pay ZERO extra work — not even the snapshot read.
1333
+ // primary scope, `serverId === "typescript"` — the sync commands this
1334
+ // races are tsserver-specific protocol extensions, not a generic
1335
+ // push-only capability, so #799 giving other servers (marksman) the
1336
+ // SAME `silentOnClean` marker must not route them into a sync attempt
1337
+ // that can never succeed for them), then the live capability-snapshot
1338
+ // tier classification (`classifyCascadeWaitTier`, which also excludes
1339
+ // native-ts7 via `launchVariant`). Every other server fails this
1340
+ // synchronous gate and pays ZERO extra work — not even the snapshot
1341
+ // read; a non-typescript `silentOnClean` server instead gets the
1342
+ // generic (non-racing) clean-confirm fallback further below.
1123
1343
  if (!notifyWriteTimedOut &&
1124
1344
  options.collectDiagnostics === true &&
1125
1345
  clientScope === "primary" &&
1126
1346
  spawned.length === 1 &&
1347
+ spawned[0].client.serverId === "typescript" &&
1127
1348
  getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1128
1349
  try {
1129
1350
  const snapshots = await this.getCapabilitySnapshots(filePath);
@@ -1156,14 +1377,29 @@ export class LSPService {
1156
1377
  spawned.some((e) => e.info.role === "auxiliary");
1157
1378
  // Per-server wait promises (each already bounded by its own
1158
1379
  // perServerTimeout — unchanged from before R8).
1380
+ let pressureSnapshots = [];
1381
+ if (shouldPreferPullOnlyDiagnostics()) {
1382
+ try {
1383
+ pressureSnapshots = await this.getCapabilitySnapshots(filePath);
1384
+ }
1385
+ catch {
1386
+ // Fail-open: missing capability state keeps today's push fallback.
1387
+ }
1388
+ }
1159
1389
  const perServerWaits = spawned.map((entry) => {
1160
1390
  const serverTimeout = timeoutFor(entry.client.serverId);
1161
1391
  const baseline = diagnosticBaselines.get(entry.client);
1392
+ const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
1162
1393
  const wait = !notifySkipped && Number.isFinite(baseline)
1163
1394
  ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
1164
1395
  minVersion: baseline,
1396
+ ...(pullOnly && { pullOnly: true }),
1165
1397
  })
1166
- : entry.client.waitForDiagnostics(filePath, serverTimeout);
1398
+ : pullOnly
1399
+ ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
1400
+ pullOnly: true,
1401
+ })
1402
+ : entry.client.waitForDiagnostics(filePath, serverTimeout);
1167
1403
  return wait.catch(() => undefined);
1168
1404
  });
1169
1405
  // The push wait — same per-server budget composition as before #707;
@@ -1327,6 +1563,69 @@ export class LSPService {
1327
1563
  },
1328
1564
  });
1329
1565
  }
1566
+ // #814: capability-aware AGGREGATE wait — generalize #799's
1567
+ // single-server (`clientScope === "primary" && spawned.length === 1`)
1568
+ // silent-clean confirm to multi-server `clientScope: "all"` touches
1569
+ // (`lens_diagnostics` mode=full per-file sweep, `lsp_diagnostics`
1570
+ // `serverScope: "all"`). #799's gate never fires here (it's scoped to
1571
+ // the primary hot path), so a scope-"all" touch where every OTHER
1572
+ // spawned server already answered but one push-only `silentOnClean`
1573
+ // server (marksman on a clean markdown file) never publishes still
1574
+ // reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut` even
1575
+ // though the "silence" is exactly what that server's own known
1576
+ // clean-behavior predicts — not an unresolved question.
1577
+ //
1578
+ // A spawned server counts as "still outstanding" when nothing landed
1579
+ // in its per-file diagnostics cache for THIS touch — `getAllDiagnostics`
1580
+ // is keyed by file and `clearDiagnosticsForPath` (`client.ts`) deletes
1581
+ // that file's entry as part of the didOpen/didChange this touch just
1582
+ // sent, so a present entry can only be a FRESH answer (found or a real
1583
+ // confirmed-empty push/pull), never a stale one bleeding through from
1584
+ // an earlier touch. This is the same "did anything publish for this
1585
+ // file since we asked" signal `cascade-tier.ts`'s Tier-3 reconcile
1586
+ // already trusts (#240 doctrine) — reused here, not reinvented.
1587
+ //
1588
+ // The touch stays inconclusive unless EVERY still-outstanding server
1589
+ // is classified `tier3-silent` (push-only + `silentOnClean`, the same
1590
+ // `classifyServerWaitTier` rule the single-server gate below and the
1591
+ // cascade lane use) — one ordinary push-only straggler (still
1592
+ // genuinely analyzing) or a pull-capable server that never answered
1593
+ // keeps the touch cautious, matching #799's "err toward caution"
1594
+ // posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
1595
+ // plus the per-server re-check below are the same "the notify write
1596
+ // must have actually landed" conservatism #799 established — a
1597
+ // server's silence is only evidence of "clean" when we know it saw
1598
+ // the new content.
1599
+ if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
1600
+ try {
1601
+ const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
1602
+ !entry.client.getAllDiagnostics().has(normalizedPath));
1603
+ if (outstanding.length > 0) {
1604
+ const snapshots = await this.getCapabilitySnapshots(filePath);
1605
+ const allSilent = outstanding.every((entry) => classifyServerWaitTier(entry.client.serverId, snapshots.find((s) => s.serverId === entry.client.serverId)) === "tier3-silent");
1606
+ if (allSilent) {
1607
+ diagnosticsTimedOut = false;
1608
+ logLatency({
1609
+ type: "phase",
1610
+ phase: "lsp_silent_clean_confirm",
1611
+ filePath: normalizedPath,
1612
+ durationMs: Date.now() - startedAt,
1613
+ metadata: {
1614
+ source,
1615
+ clientScope,
1616
+ diagnosticsMode,
1617
+ aggregate: true,
1618
+ serverIds: outstanding.map((entry) => entry.client.serverId),
1619
+ },
1620
+ });
1621
+ }
1622
+ }
1623
+ }
1624
+ catch {
1625
+ // Fail-safe: leave `diagnosticsTimedOut` as-is — today's
1626
+ // inconclusive behavior, exactly like the single-server gate.
1627
+ }
1628
+ }
1330
1629
  }
1331
1630
  // #707: when the racing sync confirm won the wait, its answer IS the
1332
1631
  // collected result — the file's real syntactic + semantic state straight
@@ -1385,6 +1684,71 @@ export class LSPService {
1385
1684
  // Any failure here falls through to today's inconclusive behavior.
1386
1685
  }
1387
1686
  }
1687
+ // #799: generalize the "silent-clean push-only" confirm beyond
1688
+ // typescript's active sync-command race above. That mechanism is
1689
+ // TS-specific (`attemptTsserverSyncDiagnostics` races an actual
1690
+ // `typescript.tsserverRequest` — no equivalent protocol exists for
1691
+ // e.g. marksman) and is now scoped to `serverId === "typescript"`
1692
+ // only (see the gate above), so it never fires for another
1693
+ // `silentOnClean` server. This is the generic fallback for those
1694
+ // servers: if the wait ran its full budget with a successful notify
1695
+ // write and nothing published, and the live capability snapshot
1696
+ // classifies this touch as tier3-silent (push-only + `silentOnClean`,
1697
+ // #458's `classifyCascadeWaitTier`), that is not "still working" —
1698
+ // `silentOnClean` means by definition this server publishes NOTHING
1699
+ // on a clean transition, so a timeout under those conditions IS the
1700
+ // confirmed-clean answer. `!tsserverSyncEligible` keeps this from
1701
+ // ever double-deciding typescript's own touches — when the sync race
1702
+ // was attempted and failed/was unavailable, typescript's existing
1703
+ // "falls through to inconclusive, unchanged" contract (#707) is
1704
+ // preserved exactly; typescript touches that never enter that gate
1705
+ // (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
1706
+ // warm-up call) are still eligible here as a genuine bonus fix. Scoped
1707
+ // to `clientScope === "primary"`/`spawned.length === 1` exactly like
1708
+ // the sync-eligible gate above (and like `ensureWarmForSweep`'s own
1709
+ // `clientScope: "primary"` warm-up touch) so a multi-server
1710
+ // with-auxiliary/all touch — where a partial timeout must stay
1711
+ // cautious per the doc below — is never affected.
1712
+ //
1713
+ // #814: this is now a SPECIAL CASE of the more general per-server gate
1714
+ // above (the `clientScope === "all"` block right before the diagnostics-
1715
+ // wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
1716
+ // server is tier3-silent" collapses to exactly this single-server check.
1717
+ // Left in place unchanged (rather than deleted/rewritten to delegate to
1718
+ // the new gate) per #814's scope: a future cleanup could fold this block
1719
+ // into the general one once both have soaked, but that's a separate,
1720
+ // lower-risk follow-up, not bundled into this fix.
1721
+ if (diagnosticsTimedOut &&
1722
+ !notifyWriteTimedOut &&
1723
+ !tsserverSyncEligible &&
1724
+ clientScope === "primary" &&
1725
+ spawned.length === 1 &&
1726
+ getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1727
+ try {
1728
+ const snapshots = await this.getCapabilitySnapshots(filePath);
1729
+ if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
1730
+ diagnosticsTimedOut = false;
1731
+ if (collected !== undefined)
1732
+ collected = mergeLspDiagnostics([]);
1733
+ logLatency({
1734
+ type: "phase",
1735
+ phase: "lsp_silent_clean_confirm",
1736
+ filePath: normalizedPath,
1737
+ durationMs: Date.now() - startedAt,
1738
+ metadata: {
1739
+ source,
1740
+ clientScope,
1741
+ diagnosticsMode,
1742
+ serverId: spawned[0].client.serverId,
1743
+ },
1744
+ });
1745
+ }
1746
+ }
1747
+ catch {
1748
+ // Fail-safe: leave `diagnosticsTimedOut` as-is — today's inconclusive
1749
+ // behavior.
1750
+ }
1751
+ }
1388
1752
  // A touch is inconclusive when EITHER the notify write or the
1389
1753
  // diagnostics wait hit their deadline for ANY of the spawned servers
1390
1754
  // (these flags are touch-wide, covering the whole `Promise.all` over
@@ -1399,8 +1763,17 @@ export class LSPService {
1399
1763
  // "actually warm" signal `ensureWarmForSweep` waits for — mark every
1400
1764
  // spawned server so a later sweep in this session sees the check as a
1401
1765
  // no-op instead of paying the warm-up round trip again.
1402
- if (diagnosticsMode !== "none" && !inconclusive) {
1766
+ //
1767
+ // #743: the diagnostics wait is a blanket (touch-wide) gate, but the
1768
+ // notify-write timeout is now PER-SERVER — a healthy server whose sibling's
1769
+ // write stalled must still be eligible, so only servers whose OWN write
1770
+ // timed out are skipped here (rather than gating the whole loop on the
1771
+ // file-level `inconclusive`).
1772
+ const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
1773
+ if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
1403
1774
  for (const entry of spawned) {
1775
+ if (notifyTimedOutServerIds.has(entry.info.id))
1776
+ continue;
1404
1777
  const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
1405
1778
  if (key)
1406
1779
  this.markDemonstratedReadyKey(key);
@@ -1457,6 +1830,12 @@ export class LSPService {
1457
1830
  collectedDiagnostics: collected?.length,
1458
1831
  notifySkipped,
1459
1832
  notifyWriteTimedOut,
1833
+ // #743: per-server detail — which servers' writes actually timed out.
1834
+ // Absent when none did. `notifyWriteTimedOut` is the file-level "at
1835
+ // least one" summary.
1836
+ ...(notifyWriteTimedOutServerIds.length > 0 && {
1837
+ notifyWriteTimedOutServerIds,
1838
+ }),
1460
1839
  diagnosticsTimedOut,
1461
1840
  inconclusive,
1462
1841
  // R8 (#714): server ids of auxiliaries whose push wait was cut off by
@@ -2030,14 +2409,45 @@ export class LSPService {
2030
2409
  * Returns `performedWarmup: true` only when the round trip actually ran
2031
2410
  * (false = already warm, no-op) — tests assert on this to guard against
2032
2411
  * the warm-up becoming a mandatory extra round trip on every sweep.
2412
+ *
2413
+ * #744: a warm-up that TIMES OUT is no longer left as a silent dead end.
2414
+ * The old one-shot behavior left a wedged server (observed live: marksman,
2415
+ * a `workspaceIndexing` server, burned the full 20s and stayed cold) with
2416
+ * no re-warm and no skip — so every subsequent per-file touch in the sweep
2417
+ * re-paid a full per-file budget against it and timed out again, dragging
2418
+ * the whole sweep. Now: on a failed warm-up this retries exactly once (after
2419
+ * a short `warmupRetryBackoffMs` breather — the state where warm-up fails is
2420
+ * usually a server mid-relaunch/index that just needs a moment), and if the
2421
+ * retry ALSO leaves the server cold it is reported in `failedServerIds`. The
2422
+ * caller (the sweep loop) skips that server's files up front and marks them
2423
+ * unconfirmed, so per-file touches stop paying its timeout. This is a
2424
+ * sweep-scoped skip (the caller discards `failedServerIds` when the sweep
2425
+ * ends), deliberately NOT the global `broken` cooldown map: a server that is
2426
+ * merely still indexing is not broken and must not be cooldown-banned across
2427
+ * the whole session — it just wasn't ready for THIS sweep.
2428
+ *
2429
+ * "Failed warm-up" is measured per non-auxiliary server via the SAME
2430
+ * `demonstratedReady` signal `touchFile` marks on a confirmed round trip: a
2431
+ * server whose key is still absent from `demonstratedReady` after both
2432
+ * attempts never proved it can answer diagnostics. Warm-up stays
2433
+ * `clientScope:"primary"` (not the sweep's `"all"`) on purpose: `"all"`
2434
+ * would additionally spawn the sweep-EXCLUDED auxiliaries
2435
+ * (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`), and because `touchFile`'s
2436
+ * `inconclusive` flag is touch-wide, one slow advisory auxiliary would then
2437
+ * suppress the `demonstratedReady` marking for a perfectly healthy primary —
2438
+ * falsely condemning it. Recording per-primary-server outcomes and skipping
2439
+ * on those is the correct, non-regressing way to cover the servers the sweep
2440
+ * actually gates on (the sweep groups by primary server, so the group this
2441
+ * warms IS the one whose per-file touches would drag).
2033
2442
  */
2034
2443
  async ensureWarmForSweep(representativeFile, options = {}) {
2035
2444
  if (this.checkDestroyed() || options.signal?.aborted) {
2036
- return { performedWarmup: false };
2445
+ return { performedWarmup: false, failedServerIds: [] };
2037
2446
  }
2038
2447
  const servers = getServersForFileWithConfig(representativeFile).filter((s) => s.role !== "auxiliary");
2039
- if (servers.length === 0)
2040
- return { performedWarmup: false };
2448
+ if (servers.length === 0) {
2449
+ return { performedWarmup: false, failedServerIds: [] };
2450
+ }
2041
2451
  // A server with no resolvable root never spawns a client for this file
2042
2452
  // either way, so it can't block "already warm" — only servers that WILL
2043
2453
  // actually be used count toward the readiness check. Same key
@@ -2047,7 +2457,43 @@ export class LSPService {
2047
2457
  const keys = await Promise.all(servers.map((server) => this.demonstratedReadyKeyFor(server, representativeFile)));
2048
2458
  const alreadyWarm = keys.every((key) => key === undefined || this.state.demonstratedReady.has(key));
2049
2459
  if (alreadyWarm)
2050
- return { performedWarmup: false };
2460
+ return { performedWarmup: false, failedServerIds: [] };
2461
+ // #799: negative cache. Every server that still needs warming (not
2462
+ // already `demonstratedReady`) was ALSO left cold by a warm-up earlier
2463
+ // this session (`demonstratedCold`, populated below when a warm-up's
2464
+ // initial attempt + retry both fail) — skip straight to the group-skip
2465
+ // accounting the caller already has for `failedServerIds`, instead of
2466
+ // re-paying the initial-attempt + retry round trip all over again. A
2467
+ // MIXED group (one server cached cold, another never tried) still runs
2468
+ // the real warm-up — the never-tried server deserves its fair shot, and
2469
+ // `touchFile`'s multi-server spawn already covers both in one call.
2470
+ const cachedColdServerIds = [];
2471
+ let allNonWarmCached = true;
2472
+ for (let i = 0; i < servers.length; i++) {
2473
+ const key = keys[i];
2474
+ if (key === undefined || this.state.demonstratedReady.has(key))
2475
+ continue;
2476
+ if (this.state.demonstratedCold.has(key)) {
2477
+ cachedColdServerIds.push(servers[i].id);
2478
+ }
2479
+ else {
2480
+ allNonWarmCached = false;
2481
+ }
2482
+ }
2483
+ if (allNonWarmCached && cachedColdServerIds.length > 0) {
2484
+ logLatency({
2485
+ type: "phase",
2486
+ phase: "lsp_sweep_warmup_cached_cold",
2487
+ filePath: representativeFile,
2488
+ durationMs: 0,
2489
+ metadata: { serverIds: cachedColdServerIds },
2490
+ });
2491
+ return {
2492
+ performedWarmup: false,
2493
+ failedServerIds: cachedColdServerIds,
2494
+ skippedFromCache: true,
2495
+ };
2496
+ }
2051
2497
  let content;
2052
2498
  try {
2053
2499
  content = await nodeFs.promises.readFile(representativeFile, "utf-8");
@@ -2055,55 +2501,129 @@ export class LSPService {
2055
2501
  catch {
2056
2502
  // Nothing to warm up with — the real sweep's own read will surface
2057
2503
  // the file error.
2058
- return { performedWarmup: false };
2504
+ return { performedWarmup: false, failedServerIds: [] };
2505
+ }
2506
+ if (options.signal?.aborted) {
2507
+ return { performedWarmup: false, failedServerIds: [] };
2059
2508
  }
2060
- if (options.signal?.aborted)
2061
- return { performedWarmup: false };
2062
2509
  const timeoutMs = options.timeoutMs ?? warmupTimeoutMs();
2063
- const startedAt = Date.now();
2064
- logLatency({
2065
- type: "phase",
2066
- phase: "lsp_sweep_warmup_start",
2067
- filePath: representativeFile,
2068
- durationMs: 0,
2069
- metadata: { serverIds: servers.map((s) => s.id), timeoutMs },
2070
- });
2071
- const warmupAttempt = this.touchFile(representativeFile, content, {
2072
- diagnostics: "document",
2073
- collectDiagnostics: false,
2074
- clientScope: "primary",
2075
- source: "lsp_sweep_warmup",
2076
- maxClientWaitMs: timeoutMs,
2077
- maxDiagnosticsWaitMs: timeoutMs,
2078
- // #669: the caller's cap here is the warm-up budget for a genuinely
2079
- // COLD server — it must act as a floor, not the usual ceiling, or
2080
- // `perServerTimeout` silently shrinks it to the strategy's normal
2081
- // warm-state `aggregateWaitMs` (e.g. 1000ms for typescript) instead
2082
- // of the requested 20000ms.
2083
- warmupOverride: true,
2084
- });
2085
- await (options.signal
2086
- ? Promise.race([
2087
- withDeadline(warmupAttempt, { ms: timeoutMs, onTimeout: "undefined" }),
2088
- new Promise((resolve) => {
2089
- if (options.signal.aborted) {
2090
- resolve();
2091
- return;
2092
- }
2093
- options.signal.addEventListener("abort", () => resolve(), {
2510
+ // A non-auxiliary server that WILL spawn for this file (resolvable key)
2511
+ // but whose key is still absent from `demonstratedReady` after an attempt
2512
+ // never proved it can answer diagnostics — that's a failed warm-up. A
2513
+ // server with an unresolvable key never spawns here, so it can't fail this
2514
+ // way and is excluded (never skipped by the caller for this file).
2515
+ const stillColdServerIds = () => {
2516
+ const cold = [];
2517
+ for (let i = 0; i < servers.length; i++) {
2518
+ const key = keys[i];
2519
+ if (key !== undefined && !this.state.demonstratedReady.has(key)) {
2520
+ cold.push(servers[i].id);
2521
+ }
2522
+ }
2523
+ return cold;
2524
+ };
2525
+ const runWarmupTouch = async (attempt) => {
2526
+ if (options.signal?.aborted)
2527
+ return;
2528
+ const startedAt = Date.now();
2529
+ logLatency({
2530
+ type: "phase",
2531
+ phase: "lsp_sweep_warmup_start",
2532
+ filePath: representativeFile,
2533
+ durationMs: 0,
2534
+ metadata: { serverIds: servers.map((s) => s.id), timeoutMs, attempt },
2535
+ });
2536
+ const warmupAttempt = this.touchFile(representativeFile, content, {
2537
+ diagnostics: "document",
2538
+ collectDiagnostics: false,
2539
+ clientScope: "primary",
2540
+ source: "lsp_sweep_warmup",
2541
+ maxClientWaitMs: timeoutMs,
2542
+ maxDiagnosticsWaitMs: timeoutMs,
2543
+ // #669: the caller's cap here is the warm-up budget for a genuinely
2544
+ // COLD server — it must act as a floor, not the usual ceiling, or
2545
+ // `perServerTimeout` silently shrinks it to the strategy's normal
2546
+ // warm-state `aggregateWaitMs` (e.g. 1000ms for typescript) instead
2547
+ // of the requested 20000ms.
2548
+ warmupOverride: true,
2549
+ // #799: only attempt 1 gets the cold-start floor — see the
2550
+ // `warmupAttempt` doc on `LSPTouchFileOptions`.
2551
+ warmupAttempt: attempt,
2552
+ });
2553
+ await (options.signal
2554
+ ? Promise.race([
2555
+ withDeadline(warmupAttempt, {
2556
+ ms: timeoutMs,
2557
+ onTimeout: "undefined",
2558
+ }),
2559
+ new Promise((resolve) => {
2560
+ if (options.signal.aborted) {
2561
+ resolve();
2562
+ return;
2563
+ }
2564
+ options.signal.addEventListener("abort", () => resolve(), {
2565
+ once: true,
2566
+ });
2567
+ }),
2568
+ ])
2569
+ : withDeadline(warmupAttempt, {
2570
+ ms: timeoutMs,
2571
+ onTimeout: "undefined",
2572
+ }));
2573
+ logLatency({
2574
+ type: "phase",
2575
+ phase: "lsp_sweep_warmup_done",
2576
+ filePath: representativeFile,
2577
+ durationMs: Date.now() - startedAt,
2578
+ metadata: {
2579
+ serverIds: servers.map((s) => s.id),
2580
+ timeoutMs,
2581
+ attempt,
2582
+ coldServerIds: stillColdServerIds(),
2583
+ },
2584
+ });
2585
+ };
2586
+ await runWarmupTouch(1);
2587
+ let failedServerIds = stillColdServerIds();
2588
+ // One retry, and only when the first attempt actually left a server cold —
2589
+ // a short backoff first so a server mid-relaunch/index gets a breather
2590
+ // rather than an immediate second hammer.
2591
+ if (failedServerIds.length > 0 && !options.signal?.aborted) {
2592
+ const backoffMs = warmupRetryBackoffMs();
2593
+ if (backoffMs > 0) {
2594
+ await new Promise((resolve) => {
2595
+ const timer = setTimeout(resolve, backoffMs);
2596
+ timer.unref?.();
2597
+ options.signal?.addEventListener("abort", () => resolve(), {
2094
2598
  once: true,
2095
2599
  });
2096
- }),
2097
- ])
2098
- : withDeadline(warmupAttempt, { ms: timeoutMs, onTimeout: "undefined" }));
2099
- logLatency({
2100
- type: "phase",
2101
- phase: "lsp_sweep_warmup_done",
2102
- filePath: representativeFile,
2103
- durationMs: Date.now() - startedAt,
2104
- metadata: { serverIds: servers.map((s) => s.id), timeoutMs },
2105
- });
2106
- return { performedWarmup: true };
2600
+ });
2601
+ }
2602
+ if (!options.signal?.aborted) {
2603
+ await runWarmupTouch(2);
2604
+ failedServerIds = stillColdServerIds();
2605
+ }
2606
+ }
2607
+ if (failedServerIds.length > 0) {
2608
+ logLatency({
2609
+ type: "phase",
2610
+ phase: "lsp_sweep_warmup_failed",
2611
+ filePath: representativeFile,
2612
+ durationMs: 0,
2613
+ metadata: { failedServerIds, timeoutMs },
2614
+ });
2615
+ // #799: record the negative cache so a LATER sweep this session
2616
+ // skips straight past re-paying this warm-up (initial + retry).
2617
+ // Cleared automatically the moment the server demonstrates
2618
+ // readiness through any path (`markDemonstratedReadyKey`).
2619
+ for (let i = 0; i < servers.length; i++) {
2620
+ const key = keys[i];
2621
+ if (key !== undefined && failedServerIds.includes(servers[i].id)) {
2622
+ this.state.demonstratedCold.add(key);
2623
+ }
2624
+ }
2625
+ }
2626
+ return { performedWarmup: true, failedServerIds };
2107
2627
  }
2108
2628
  /**
2109
2629
  * Actively scan every LSP-supported source file under a project root.
@@ -2252,6 +2772,8 @@ export class LSPService {
2252
2772
  // existing group-worker pool.
2253
2773
  const contentCache = new Map();
2254
2774
  const preOpenGroupFiles = async (groupFiles) => {
2775
+ if (isWarmAttached())
2776
+ return;
2255
2777
  for (const filePath of groupFiles) {
2256
2778
  if (signal?.aborted)
2257
2779
  return;
@@ -2333,20 +2855,28 @@ export class LSPService {
2333
2855
  // onTimeout:"undefined" so a hung file yields no diagnostics and the
2334
2856
  // worker moves on; a real touchFile rejection still propagates to the
2335
2857
  // catch below and is recorded as an error.
2336
- const diagnostics = await withDeadline(this.touchFile(filePath, content, {
2337
- diagnostics: "document",
2338
- collectDiagnostics: true,
2339
- clientScope: "all",
2340
- source: "lens_diagnostics_full",
2341
- // #584: opengrep's findings for a full sweep come from the
2342
- // `opengrep-client.ts` CLI extractor (one project-wide scan,
2343
- // cached, read via extractors.ts) instead — see the
2344
- // `excludeServerIds` doc on `LSPTouchFileOptions`.
2345
- excludeServerIds: WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS,
2346
- // #645: lets a workspaceIndexing server (marksman) pay its
2347
- // full wait budget only once across this whole sweep.
2348
- sweepIndexGate,
2349
- }), { ms: perFileMs, onTimeout: "undefined" });
2858
+ const attached = isWarmAttached()
2859
+ ? await tryWarmAttachedDiagnostics(filePath, content, perFileMs, "sweep")
2860
+ : undefined;
2861
+ if (attached && !attached.available) {
2862
+ await this.ensureWarmForSweep(filePath, { signal });
2863
+ }
2864
+ const diagnostics = attached?.available
2865
+ ? attached.response.diagnostics
2866
+ : await withDeadline(this.touchFile(filePath, content, {
2867
+ diagnostics: "document",
2868
+ collectDiagnostics: true,
2869
+ clientScope: "all",
2870
+ source: "lens_diagnostics_full",
2871
+ // #584: opengrep's findings for a full sweep come from the
2872
+ // `opengrep-client.ts` CLI extractor (one project-wide scan,
2873
+ // cached, read via extractors.ts) instead — see the
2874
+ // `excludeServerIds` doc on `LSPTouchFileOptions`.
2875
+ excludeServerIds: WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS,
2876
+ // #645: lets a workspaceIndexing server (marksman) pay its
2877
+ // full wait budget only once across this whole sweep.
2878
+ sweepIndexGate,
2879
+ }), { ms: perFileMs, onTimeout: "undefined" });
2350
2880
  // #571: prefer #570's real per-touch inconclusive signal
2351
2881
  // (`touchFile`'s non-enumerable `.inconclusive` flag — set when the
2352
2882
  // notify write or the diagnostics wait itself timed out) over this
@@ -2426,7 +2956,9 @@ export class LSPService {
2426
2956
  if (signal?.aborted)
2427
2957
  return;
2428
2958
  // Fast path: one project-wide pull for the whole group (opt-in).
2429
- if (workspacePullEnabled && !group.multiServer) {
2959
+ if (!isWarmAttached() &&
2960
+ workspacePullEnabled &&
2961
+ !group.multiServer) {
2430
2962
  const pulled = await this.tryWorkspacePull(group.files, perFileMs);
2431
2963
  if (pulled) {
2432
2964
  for (const result of pulled) {
@@ -2457,10 +2989,48 @@ export class LSPService {
2457
2989
  // budget in one shot — the per-file "first N files eat individual
2458
2990
  // timeouts" failure mode this fixes doesn't apply there.
2459
2991
  const first = group.files[0];
2460
- if (first) {
2461
- await this.ensureWarmForSweep(first, { signal });
2992
+ if (first && !isWarmAttached()) {
2993
+ const warmup = await this.ensureWarmForSweep(first, { signal });
2462
2994
  if (signal?.aborted)
2463
2995
  return;
2996
+ // #744: the group's primary server failed warm-up (initial round
2997
+ // trip + one retry both left it cold). Every per-file touch to it
2998
+ // would re-pay its full timeout and time out again, dragging the
2999
+ // whole sweep — the exact wedged-marksman failure mode this closes.
3000
+ // So skip this group's files and record each as UNCONFIRMED
3001
+ // (timedOut + skippedWarmupFailure), never as confirmed-clean `[]`:
3002
+ // the group is keyed by its primary server, so a non-empty
3003
+ // `failedServerIds` means that primary is the one that couldn't warm.
3004
+ if (warmup.failedServerIds.length > 0) {
3005
+ logLatency({
3006
+ type: "phase",
3007
+ phase: "lsp_sweep_group_skipped_warmup",
3008
+ filePath: first,
3009
+ durationMs: 0,
3010
+ metadata: {
3011
+ failedServerIds: warmup.failedServerIds,
3012
+ // #799: distinguishes a fresh warm-up failure from a
3013
+ // negative-cache hit (this sweep never re-attempted warm-up
3014
+ // at all — it was already known cold from earlier this
3015
+ // session).
3016
+ skippedFromCache: warmup.skippedFromCache ?? false,
3017
+ skippedFiles: group.files.length,
3018
+ },
3019
+ });
3020
+ for (const filePath of group.files) {
3021
+ results.push({
3022
+ filePath,
3023
+ diagnostics: [],
3024
+ count: 0,
3025
+ timedOut: true,
3026
+ skippedWarmupFailure: true,
3027
+ });
3028
+ timedOutFiles += 1;
3029
+ completed += 1;
3030
+ }
3031
+ options.onProgress?.(completed, files.length);
3032
+ return;
3033
+ }
2464
3034
  }
2465
3035
  // #608/#621: batch-open a CHUNK of this group's files before
2466
3036
  // waiting on diagnostics for any of them individually — see
@@ -2575,6 +3145,7 @@ export class LSPService {
2575
3145
  catch {
2576
3146
  /* missing → will be pruned */
2577
3147
  }
3148
+ return true;
2578
3149
  }));
2579
3150
  client.pruneDiagnostics((filePath, ts) => !existingPaths.has(filePath) ||
2580
3151
  now - ts > CASCADE_DIAGNOSTICS_TTL_MS);
@@ -2658,14 +3229,13 @@ export class LSPService {
2658
3229
  processExiting: !!options.processExiting,
2659
3230
  },
2660
3231
  });
2661
- for (const [_key, client] of this.state.clients) {
2662
- try {
2663
- await client.shutdown(options);
2664
- }
2665
- catch {
2666
- // pi-lens-ignore: missing-error-propagation — per-client shutdown failure, must not abort remaining shutdowns
2667
- }
2668
- }
3232
+ // Start every client teardown before awaiting any of them. A non-fast
3233
+ // process-tree kill can spend its grace period per client, so awaiting in
3234
+ // map order makes the reset tail O(clientCount * grace) instead of the
3235
+ // maximum individual teardown. allSettled preserves the per-client
3236
+ // best-effort contract: one failure must not prevent other clients from
3237
+ // finishing, and the caller still waits for every teardown to settle.
3238
+ await Promise.allSettled(Array.from(this.state.clients.values(), (client) => Promise.resolve().then(() => client.shutdown(options))));
2669
3239
  this.state.clients.clear();
2670
3240
  this.state.broken.clear();
2671
3241
  this.workspaceProbeLogged.clear();
@@ -2717,25 +3287,53 @@ export class LSPService {
2717
3287
  }
2718
3288
  // --- Singleton Instance ---
2719
3289
  let globalLSPService = null;
3290
+ /**
3291
+ * #850: all singleton generations whose teardown is still pending. A new
3292
+ * generation may be allocated synchronously, but its first spawn waits on this
3293
+ * handoff so two generations can never own the same server/root concurrently.
3294
+ */
3295
+ let globalLSPGenerationHandoff;
2720
3296
  export function getLSPService() {
2721
3297
  if (!globalLSPService) {
2722
- globalLSPService = new LSPService();
3298
+ globalLSPService = new LSPService(globalLSPGenerationHandoff);
2723
3299
  }
2724
3300
  return globalLSPService;
2725
3301
  }
3302
+ /**
3303
+ * Cross-layer liveness seam for dispatch-side auxiliary gates. This is a
3304
+ * liveness read only: it does not spawn, wait for initialization, or probe a
3305
+ * binary.
3306
+ */
3307
+ export async function isAuxiliaryLspAlive(serverId, filePath) {
3308
+ return getLSPService().isServerAliveForFile(serverId, filePath);
3309
+ }
2726
3310
  export function resetLSPService(options = {}) {
2727
- if (globalLSPService) {
2728
- globalLSPService.shutdown(options).catch(() => { });
2729
- }
3311
+ const retiringService = globalLSPService;
2730
3312
  globalLSPService = null;
3313
+ if (!retiringService)
3314
+ return;
3315
+ // shutdown() marks the service destroyed synchronously before its first
3316
+ // await. Include both that teardown and every earlier pending generation:
3317
+ // repeated resets may retire a replacement that is itself still waiting on
3318
+ // its predecessor. allSettled keeps teardown best-effort without ever
3319
+ // rejecting (and therefore permanently poisoning) the next generation.
3320
+ const teardown = retiringService.shutdown(options);
3321
+ const pending = globalLSPGenerationHandoff
3322
+ ? [globalLSPGenerationHandoff, teardown]
3323
+ : [teardown];
3324
+ const handoff = Promise.allSettled(pending).then(() => undefined);
3325
+ globalLSPGenerationHandoff = handoff;
3326
+ void handoff.then(() => {
3327
+ if (globalLSPGenerationHandoff === handoff) {
3328
+ globalLSPGenerationHandoff = undefined;
3329
+ }
3330
+ });
2731
3331
  }
2732
3332
  /**
2733
3333
  * Test-only: exposes the async workspace-diagnostics file walk so its
2734
3334
  * event-loop occupancy can be guarded (see workspace-diagnostics-occupancy
2735
3335
  * test). Not part of the public API.
2736
3336
  */
2737
- export function __collectWorkspaceDiagnosticFilesForTest(root, maxFiles, signal) {
2738
- return maxFiles === undefined
2739
- ? collectWorkspaceDiagnosticFiles(path.resolve(root), undefined, signal)
2740
- : collectWorkspaceDiagnosticFiles(path.resolve(root), maxFiles, signal);
3337
+ export function __collectWorkspaceDiagnosticFilesForTest(root, maxFiles, signal, homeDir) {
3338
+ return collectWorkspaceDiagnosticFiles(path.resolve(root), maxFiles ?? getMaxWorkspaceDiagnosticFiles(), signal, homeDir);
2741
3339
  }