pi-lens 3.8.71 → 3.8.72

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 (79) hide show
  1. package/CHANGELOG.md +106 -1
  2. package/README.md +8 -0
  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/diagnostic-dispositions.js +64 -2
  7. package/dist/clients/dispatch/integration.js +2 -1
  8. package/dist/clients/dispatch/runners/lsp.js +54 -25
  9. package/dist/clients/dispatch/runners/markdownlint.js +7 -4
  10. package/dist/clients/dispatch/runners/pyright.js +1 -0
  11. package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
  12. package/dist/clients/env-utils.js +38 -0
  13. package/dist/clients/file-utils.js +21 -3
  14. package/dist/clients/instance-registry.js +20 -20
  15. package/dist/clients/jscpd-client.js +52 -39
  16. package/dist/clients/language-profile.js +12 -19
  17. package/dist/clients/lens-config.js +106 -27
  18. package/dist/clients/lens-engine.js +129 -3
  19. package/dist/clients/lsp/cascade-tier.js +1 -38
  20. package/dist/clients/lsp/client.js +3 -1
  21. package/dist/clients/lsp/index.js +621 -95
  22. package/dist/clients/lsp/server-strategies.js +3 -224
  23. package/dist/clients/lsp/server.js +1 -1
  24. package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
  25. package/dist/clients/lsp/wait-policy/classification.js +63 -0
  26. package/dist/clients/lsp/wait-policy/index.js +10 -0
  27. package/dist/clients/lsp/wait-policy/strategies.js +244 -0
  28. package/dist/clients/lsp-budget.js +54 -16
  29. package/dist/clients/mcp/analyze.js +1 -1
  30. package/dist/clients/mcp/host-shim.js +12 -8
  31. package/dist/clients/mcp/ipc.js +118 -0
  32. package/dist/clients/mcp/session.js +2 -2
  33. package/dist/clients/pipeline.js +11 -6
  34. package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
  35. package/dist/clients/project-diagnostics/scanner.js +37 -6
  36. package/dist/clients/project-lens-config.js +167 -3
  37. package/dist/clients/project-report.js +627 -0
  38. package/dist/clients/project-scale.js +239 -0
  39. package/dist/clients/project-scan-policy.js +11 -1
  40. package/dist/clients/recent-touches.js +6 -11
  41. package/dist/clients/review-graph/builder.js +170 -13
  42. package/dist/clients/review-graph/import-resolvers.js +149 -13
  43. package/dist/clients/review-graph/tsconfig-paths.js +242 -0
  44. package/dist/clients/review-graph/workspace-modules.js +21 -9
  45. package/dist/clients/rules-scanner.js +15 -2
  46. package/dist/clients/runtime-agent-end.js +104 -17
  47. package/dist/clients/runtime-config.js +4 -0
  48. package/dist/clients/runtime-coordinator.js +50 -1
  49. package/dist/clients/runtime-session.js +41 -3
  50. package/dist/clients/runtime-tool-result.js +4 -3
  51. package/dist/clients/runtime-turn.js +4 -1
  52. package/dist/clients/safe-spawn.js +264 -40
  53. package/dist/clients/session-lifecycle.js +36 -0
  54. package/dist/clients/session-state-store.js +8 -3
  55. package/dist/clients/source-filter.js +136 -61
  56. package/dist/clients/source-walker.js +89 -0
  57. package/dist/clients/startup-scan.js +189 -111
  58. package/dist/clients/subagent-mode.js +14 -3
  59. package/dist/clients/todo-scanner.js +5 -1
  60. package/dist/clients/tool-policy.js +26 -4
  61. package/dist/clients/warm-attach.js +204 -0
  62. package/dist/clients/word-index.js +36 -5
  63. package/dist/clients/workspace-topology.js +266 -0
  64. package/dist/index.js +13826 -10251
  65. package/dist/mcp/server.js +125 -10
  66. package/dist/tools/ast-grep-search.js +21 -0
  67. package/dist/tools/lens-diagnostic-mark.js +198 -7
  68. package/dist/tools/lens-diagnostics.js +119 -21
  69. package/dist/tools/lsp-diagnostics.js +17 -1
  70. package/dist/tools/lsp-navigation.js +11 -2
  71. package/dist/tools/project-report.js +94 -0
  72. package/dist/tools/symbol-search.js +32 -8
  73. package/docs/environment-variables.md +10 -0
  74. package/docs/features.md +7 -1
  75. package/docs/globalconfig.md +70 -0
  76. package/docs/servercapabilities.md +1 -1
  77. package/docs/usage.md +13 -2
  78. package/package.json +1 -1
  79. 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,6 +429,15 @@ 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
443
  constructor() {
@@ -413,6 +448,7 @@ export class LSPService {
413
448
  inFlight: new Map(),
414
449
  clientSpawnedAt: new Map(),
415
450
  demonstratedReady: new Set(),
451
+ demonstratedCold: new Set(),
416
452
  };
417
453
  }
418
454
  /** Guard: return true if service is shutting down or shut down */
@@ -498,6 +534,46 @@ export class LSPService {
498
534
  }
499
535
  markDemonstratedReadyKey(key) {
500
536
  this.state.demonstratedReady.add(key);
537
+ // #799: readiness through ANY path supersedes an earlier cold verdict —
538
+ // a server that recovers later in the session must not stay stuck in
539
+ // the negative cache.
540
+ this.state.demonstratedCold.delete(key);
541
+ }
542
+ /**
543
+ * #743: record one notify-write timeout for a server and, once it has stalled
544
+ * {@link NOTIFY_BACKPRESSURE_BROKEN_AFTER} times in a row, demote it through
545
+ * the EXISTING broken-cooldown map so subsequent sweeps stop re-paying its
546
+ * notify budget on every file. The wedged client is also evicted: an alive
547
+ * client is reused before the broken check in {@link ensureClientForServer},
548
+ * so the cooldown only bites once the stale client is gone. `key` is
549
+ * "serverId:normalizedRoot" (the broken-map identity); undefined when the
550
+ * server's root could not be resolved, in which case there is nothing to key.
551
+ */
552
+ recordNotifyWriteBackpressure(key, entry, filePath) {
553
+ if (!key)
554
+ return;
555
+ const streak = (this.notifyWriteBackpressureStreak.get(key) ?? 0) + 1;
556
+ if (streak < NOTIFY_BACKPRESSURE_BROKEN_AFTER) {
557
+ this.notifyWriteBackpressureStreak.set(key, streak);
558
+ return;
559
+ }
560
+ this.notifyWriteBackpressureStreak.delete(key);
561
+ this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
562
+ void entry.client.shutdown().catch(() => { });
563
+ this.state.clients.delete(key);
564
+ this.state.clientSpawnedAt.delete(key);
565
+ this.state.demonstratedReady.delete(key);
566
+ logLatency({
567
+ type: "phase",
568
+ phase: "lsp_notify_backpressure_broken",
569
+ filePath: normalizeMapKey(filePath),
570
+ durationMs: 0,
571
+ metadata: {
572
+ serverId: entry.info.id,
573
+ cooldownMs: BROKEN_BASE_COOLDOWN_MS,
574
+ consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
575
+ },
576
+ });
501
577
  }
502
578
  activeClientsForCwd(cwd, priorityServerIds = []) {
503
579
  const normalizedCwd = normalizeMapKey(cwd);
@@ -986,23 +1062,70 @@ export class LSPService {
986
1062
  // runner touch sequence expensive on slow TS projects.
987
1063
  const notifySkipped = this.shouldSkipNotify(filePath, content, clientScope);
988
1064
  const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
989
- let notifyWriteTimedOut = false;
1065
+ // #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
1066
+ // write gets its OWN notifyWriteBudgetMs budget rather than one shared
1067
+ // deadline over a single Promise.all — otherwise one backpressured server
1068
+ // (stalled stdin) times out the write for the ENTIRE file, flipping every
1069
+ // co-touched healthy server to inconclusive and zeroing its diagnostics.
1070
+ // Bounded so a backpressured write can't hang the caller; on timeout we
1071
+ // proceed — the diagnostics wait below is separately bounded and simply
1072
+ // returns no fresh diagnostics for the server(s) that stalled.
1073
+ //
1074
+ // Holds the serverId of every server whose write did NOT land in time.
1075
+ // The file-level `notifyWriteTimedOut` (logged below) means "at least one
1076
+ // server timed out"; this list carries the per-server detail the
1077
+ // demonstratedReady gate reads so a healthy sibling stays eligible.
1078
+ const notifyWriteTimedOutServerIds = [];
990
1079
  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;
1080
+ const budget = notifyWriteBudgetMs();
1081
+ await Promise.all(spawned.map(async (entry) => {
1082
+ // Same identity as the broken/demonstratedReady maps.
1083
+ const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
1084
+ let wrote;
1085
+ let rejected = false;
1086
+ try {
1087
+ wrote = await withDeadline(entry.client.notify
1088
+ .open(filePath, content, languageId, undefined, silent)
1089
+ .then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
1090
+ }
1091
+ catch {
1092
+ // The write itself rejected (not backpressure): the content did
1093
+ // not land, so this server is inconclusive for the touch, but a
1094
+ // rejection is not a stdin-backpressure signal and must not count
1095
+ // toward the backpressure demotion streak.
1096
+ rejected = true;
1097
+ }
1098
+ if (wrote === true) {
1099
+ // A clean write clears any accrued backpressure streak (#743).
1100
+ if (clientKey)
1101
+ this.notifyWriteBackpressureStreak.delete(clientKey);
1102
+ }
1103
+ else {
1104
+ notifyWriteTimedOutServerIds.push(entry.info.id);
1105
+ if (!rejected) {
1106
+ this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
1107
+ }
1108
+ }
1109
+ return true;
1110
+ }));
1111
+ if (notifyWriteTimedOutServerIds.length > 0) {
997
1112
  logLatency({
998
1113
  type: "phase",
999
1114
  phase: "lsp_notify_timeout",
1000
1115
  filePath: normalizedPath,
1001
1116
  durationMs: Date.now() - startedAt,
1002
- metadata: { source, clientScope, serverCount: spawned.length },
1117
+ metadata: {
1118
+ source,
1119
+ clientScope,
1120
+ serverCount: spawned.length,
1121
+ timedOutServerIds: notifyWriteTimedOutServerIds,
1122
+ },
1003
1123
  });
1004
1124
  }
1005
1125
  }
1126
+ // File-level flag: at least one server's write timed out (kept for the
1127
+ // conservative touch-wide `inconclusive` merge semantics — see below).
1128
+ const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
1006
1129
  let diagnosticsTimedOut = false;
1007
1130
  // R8 (#714): server ids of aux-role servers whose push wait was cut off by
1008
1131
  // the aux grace window. Undefined when no aux was cut off (primary-only
@@ -1014,7 +1137,7 @@ export class LSPService {
1014
1137
  // (undefined = the race didn't produce an answer; the end-of-wait
1015
1138
  // fallback below may still fill it in on a timed-out empty result).
1016
1139
  let tsserverSyncEligible = false;
1017
- let tsserverSyncConfirmed = undefined;
1140
+ let tsserverSyncConfirmed;
1018
1141
  if (diagnosticsMode !== "none") {
1019
1142
  // Resolution: env wins so users can tune the cap without rebuilding.
1020
1143
  // Otherwise, on the single-server hot path (primary scope), use that
@@ -1061,6 +1184,44 @@ export class LSPService {
1061
1184
  }
1062
1185
  }
1063
1186
  }
1187
+ // #832: workspace-indexing servers that are classified as silent on
1188
+ // clean do not benefit from the generic cold-indexing floor. Their
1189
+ // configured strategy already gives the first sweep touch a bounded
1190
+ // workspace-index budget (marksman: 1500ms), while the capability
1191
+ // classification proves that a clean push has no affirmative signal to
1192
+ // wait for. Keep this restricted to the workspace-indexing strategy:
1193
+ // TypeScript is also a silent-on-clean push server, but its cold project
1194
+ // load still needs the longer 20s floor.
1195
+ //
1196
+ // Build this from the live spawned client's capabilities rather than
1197
+ // server id alone. Missing/throwing capability data fails closed, so a
1198
+ // new or ambiguous server keeps the existing generous warm-up budget.
1199
+ const silentCleanWarmupServers = new Set();
1200
+ if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
1201
+ for (const entry of spawned) {
1202
+ const strategy = getStrategy(entry.client.serverId);
1203
+ if (strategy.workspaceIndexing !== true)
1204
+ continue;
1205
+ try {
1206
+ const snapshot = {
1207
+ serverId: entry.client.serverId,
1208
+ root: entry.client.root,
1209
+ operationSupport: entry.client.getOperationSupport(),
1210
+ workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
1211
+ advertisedCommands: entry.client.getAdvertisedCommands(),
1212
+ rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
1213
+ launchVariant: entry.client.getLaunchVariant?.(),
1214
+ };
1215
+ if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
1216
+ "tier3-silent") {
1217
+ silentCleanWarmupServers.add(entry.client.serverId);
1218
+ }
1219
+ }
1220
+ catch {
1221
+ // Fail closed: capability uncertainty must retain the cold floor.
1222
+ }
1223
+ }
1224
+ }
1064
1225
  // Each server gets its OWN deadline, bounded by the caller cap as a
1065
1226
  // CEILING (never a floor) — so a clean push-silent primary (typescript
1066
1227
  // ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
@@ -1088,6 +1249,18 @@ export class LSPService {
1088
1249
  // if the strategy already wants more) rather than the normal
1089
1250
  // ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
1090
1251
  if (options.warmupOverride) {
1252
+ // #832: a workspace-indexing server already classified as
1253
+ // silent-on-clean uses its strategy's bounded wait on the first
1254
+ // attempt; the generic cold floor is for servers whose cold work
1255
+ // can eventually produce a push answer (notably TypeScript).
1256
+ if (silentCleanWarmupServers.has(serverId)) {
1257
+ return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1258
+ }
1259
+ // #799: only the FIRST warm-up attempt for a cold server gets the
1260
+ // floor — see the `warmupAttempt` doc on `LSPTouchFileOptions`.
1261
+ if ((options.warmupAttempt ?? 1) > 1) {
1262
+ return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1263
+ }
1091
1264
  return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
1092
1265
  }
1093
1266
  return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
@@ -1114,16 +1287,21 @@ export class LSPService {
1114
1287
  const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
1115
1288
  // #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
1116
1289
  // 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.
1290
+ // primary scope, `serverId === "typescript"` — the sync commands this
1291
+ // races are tsserver-specific protocol extensions, not a generic
1292
+ // push-only capability, so #799 giving other servers (marksman) the
1293
+ // SAME `silentOnClean` marker must not route them into a sync attempt
1294
+ // that can never succeed for them), then the live capability-snapshot
1295
+ // tier classification (`classifyCascadeWaitTier`, which also excludes
1296
+ // native-ts7 via `launchVariant`). Every other server fails this
1297
+ // synchronous gate and pays ZERO extra work — not even the snapshot
1298
+ // read; a non-typescript `silentOnClean` server instead gets the
1299
+ // generic (non-racing) clean-confirm fallback further below.
1123
1300
  if (!notifyWriteTimedOut &&
1124
1301
  options.collectDiagnostics === true &&
1125
1302
  clientScope === "primary" &&
1126
1303
  spawned.length === 1 &&
1304
+ spawned[0].client.serverId === "typescript" &&
1127
1305
  getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1128
1306
  try {
1129
1307
  const snapshots = await this.getCapabilitySnapshots(filePath);
@@ -1156,14 +1334,29 @@ export class LSPService {
1156
1334
  spawned.some((e) => e.info.role === "auxiliary");
1157
1335
  // Per-server wait promises (each already bounded by its own
1158
1336
  // perServerTimeout — unchanged from before R8).
1337
+ let pressureSnapshots = [];
1338
+ if (shouldPreferPullOnlyDiagnostics()) {
1339
+ try {
1340
+ pressureSnapshots = await this.getCapabilitySnapshots(filePath);
1341
+ }
1342
+ catch {
1343
+ // Fail-open: missing capability state keeps today's push fallback.
1344
+ }
1345
+ }
1159
1346
  const perServerWaits = spawned.map((entry) => {
1160
1347
  const serverTimeout = timeoutFor(entry.client.serverId);
1161
1348
  const baseline = diagnosticBaselines.get(entry.client);
1349
+ const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
1162
1350
  const wait = !notifySkipped && Number.isFinite(baseline)
1163
1351
  ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
1164
1352
  minVersion: baseline,
1353
+ ...(pullOnly && { pullOnly: true }),
1165
1354
  })
1166
- : entry.client.waitForDiagnostics(filePath, serverTimeout);
1355
+ : pullOnly
1356
+ ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
1357
+ pullOnly: true,
1358
+ })
1359
+ : entry.client.waitForDiagnostics(filePath, serverTimeout);
1167
1360
  return wait.catch(() => undefined);
1168
1361
  });
1169
1362
  // The push wait — same per-server budget composition as before #707;
@@ -1327,6 +1520,69 @@ export class LSPService {
1327
1520
  },
1328
1521
  });
1329
1522
  }
1523
+ // #814: capability-aware AGGREGATE wait — generalize #799's
1524
+ // single-server (`clientScope === "primary" && spawned.length === 1`)
1525
+ // silent-clean confirm to multi-server `clientScope: "all"` touches
1526
+ // (`lens_diagnostics` mode=full per-file sweep, `lsp_diagnostics`
1527
+ // `serverScope: "all"`). #799's gate never fires here (it's scoped to
1528
+ // the primary hot path), so a scope-"all" touch where every OTHER
1529
+ // spawned server already answered but one push-only `silentOnClean`
1530
+ // server (marksman on a clean markdown file) never publishes still
1531
+ // reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut` even
1532
+ // though the "silence" is exactly what that server's own known
1533
+ // clean-behavior predicts — not an unresolved question.
1534
+ //
1535
+ // A spawned server counts as "still outstanding" when nothing landed
1536
+ // in its per-file diagnostics cache for THIS touch — `getAllDiagnostics`
1537
+ // is keyed by file and `clearDiagnosticsForPath` (`client.ts`) deletes
1538
+ // that file's entry as part of the didOpen/didChange this touch just
1539
+ // sent, so a present entry can only be a FRESH answer (found or a real
1540
+ // confirmed-empty push/pull), never a stale one bleeding through from
1541
+ // an earlier touch. This is the same "did anything publish for this
1542
+ // file since we asked" signal `cascade-tier.ts`'s Tier-3 reconcile
1543
+ // already trusts (#240 doctrine) — reused here, not reinvented.
1544
+ //
1545
+ // The touch stays inconclusive unless EVERY still-outstanding server
1546
+ // is classified `tier3-silent` (push-only + `silentOnClean`, the same
1547
+ // `classifyServerWaitTier` rule the single-server gate below and the
1548
+ // cascade lane use) — one ordinary push-only straggler (still
1549
+ // genuinely analyzing) or a pull-capable server that never answered
1550
+ // keeps the touch cautious, matching #799's "err toward caution"
1551
+ // posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
1552
+ // plus the per-server re-check below are the same "the notify write
1553
+ // must have actually landed" conservatism #799 established — a
1554
+ // server's silence is only evidence of "clean" when we know it saw
1555
+ // the new content.
1556
+ if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
1557
+ try {
1558
+ const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
1559
+ !entry.client.getAllDiagnostics().has(normalizedPath));
1560
+ if (outstanding.length > 0) {
1561
+ const snapshots = await this.getCapabilitySnapshots(filePath);
1562
+ const allSilent = outstanding.every((entry) => classifyServerWaitTier(entry.client.serverId, snapshots.find((s) => s.serverId === entry.client.serverId)) === "tier3-silent");
1563
+ if (allSilent) {
1564
+ diagnosticsTimedOut = false;
1565
+ logLatency({
1566
+ type: "phase",
1567
+ phase: "lsp_silent_clean_confirm",
1568
+ filePath: normalizedPath,
1569
+ durationMs: Date.now() - startedAt,
1570
+ metadata: {
1571
+ source,
1572
+ clientScope,
1573
+ diagnosticsMode,
1574
+ aggregate: true,
1575
+ serverIds: outstanding.map((entry) => entry.client.serverId),
1576
+ },
1577
+ });
1578
+ }
1579
+ }
1580
+ }
1581
+ catch {
1582
+ // Fail-safe: leave `diagnosticsTimedOut` as-is — today's
1583
+ // inconclusive behavior, exactly like the single-server gate.
1584
+ }
1585
+ }
1330
1586
  }
1331
1587
  // #707: when the racing sync confirm won the wait, its answer IS the
1332
1588
  // collected result — the file's real syntactic + semantic state straight
@@ -1385,6 +1641,71 @@ export class LSPService {
1385
1641
  // Any failure here falls through to today's inconclusive behavior.
1386
1642
  }
1387
1643
  }
1644
+ // #799: generalize the "silent-clean push-only" confirm beyond
1645
+ // typescript's active sync-command race above. That mechanism is
1646
+ // TS-specific (`attemptTsserverSyncDiagnostics` races an actual
1647
+ // `typescript.tsserverRequest` — no equivalent protocol exists for
1648
+ // e.g. marksman) and is now scoped to `serverId === "typescript"`
1649
+ // only (see the gate above), so it never fires for another
1650
+ // `silentOnClean` server. This is the generic fallback for those
1651
+ // servers: if the wait ran its full budget with a successful notify
1652
+ // write and nothing published, and the live capability snapshot
1653
+ // classifies this touch as tier3-silent (push-only + `silentOnClean`,
1654
+ // #458's `classifyCascadeWaitTier`), that is not "still working" —
1655
+ // `silentOnClean` means by definition this server publishes NOTHING
1656
+ // on a clean transition, so a timeout under those conditions IS the
1657
+ // confirmed-clean answer. `!tsserverSyncEligible` keeps this from
1658
+ // ever double-deciding typescript's own touches — when the sync race
1659
+ // was attempted and failed/was unavailable, typescript's existing
1660
+ // "falls through to inconclusive, unchanged" contract (#707) is
1661
+ // preserved exactly; typescript touches that never enter that gate
1662
+ // (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
1663
+ // warm-up call) are still eligible here as a genuine bonus fix. Scoped
1664
+ // to `clientScope === "primary"`/`spawned.length === 1` exactly like
1665
+ // the sync-eligible gate above (and like `ensureWarmForSweep`'s own
1666
+ // `clientScope: "primary"` warm-up touch) so a multi-server
1667
+ // with-auxiliary/all touch — where a partial timeout must stay
1668
+ // cautious per the doc below — is never affected.
1669
+ //
1670
+ // #814: this is now a SPECIAL CASE of the more general per-server gate
1671
+ // above (the `clientScope === "all"` block right before the diagnostics-
1672
+ // wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
1673
+ // server is tier3-silent" collapses to exactly this single-server check.
1674
+ // Left in place unchanged (rather than deleted/rewritten to delegate to
1675
+ // the new gate) per #814's scope: a future cleanup could fold this block
1676
+ // into the general one once both have soaked, but that's a separate,
1677
+ // lower-risk follow-up, not bundled into this fix.
1678
+ if (diagnosticsTimedOut &&
1679
+ !notifyWriteTimedOut &&
1680
+ !tsserverSyncEligible &&
1681
+ clientScope === "primary" &&
1682
+ spawned.length === 1 &&
1683
+ getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1684
+ try {
1685
+ const snapshots = await this.getCapabilitySnapshots(filePath);
1686
+ if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
1687
+ diagnosticsTimedOut = false;
1688
+ if (collected !== undefined)
1689
+ collected = mergeLspDiagnostics([]);
1690
+ logLatency({
1691
+ type: "phase",
1692
+ phase: "lsp_silent_clean_confirm",
1693
+ filePath: normalizedPath,
1694
+ durationMs: Date.now() - startedAt,
1695
+ metadata: {
1696
+ source,
1697
+ clientScope,
1698
+ diagnosticsMode,
1699
+ serverId: spawned[0].client.serverId,
1700
+ },
1701
+ });
1702
+ }
1703
+ }
1704
+ catch {
1705
+ // Fail-safe: leave `diagnosticsTimedOut` as-is — today's inconclusive
1706
+ // behavior.
1707
+ }
1708
+ }
1388
1709
  // A touch is inconclusive when EITHER the notify write or the
1389
1710
  // diagnostics wait hit their deadline for ANY of the spawned servers
1390
1711
  // (these flags are touch-wide, covering the whole `Promise.all` over
@@ -1399,8 +1720,17 @@ export class LSPService {
1399
1720
  // "actually warm" signal `ensureWarmForSweep` waits for — mark every
1400
1721
  // spawned server so a later sweep in this session sees the check as a
1401
1722
  // no-op instead of paying the warm-up round trip again.
1402
- if (diagnosticsMode !== "none" && !inconclusive) {
1723
+ //
1724
+ // #743: the diagnostics wait is a blanket (touch-wide) gate, but the
1725
+ // notify-write timeout is now PER-SERVER — a healthy server whose sibling's
1726
+ // write stalled must still be eligible, so only servers whose OWN write
1727
+ // timed out are skipped here (rather than gating the whole loop on the
1728
+ // file-level `inconclusive`).
1729
+ const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
1730
+ if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
1403
1731
  for (const entry of spawned) {
1732
+ if (notifyTimedOutServerIds.has(entry.info.id))
1733
+ continue;
1404
1734
  const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
1405
1735
  if (key)
1406
1736
  this.markDemonstratedReadyKey(key);
@@ -1457,6 +1787,12 @@ export class LSPService {
1457
1787
  collectedDiagnostics: collected?.length,
1458
1788
  notifySkipped,
1459
1789
  notifyWriteTimedOut,
1790
+ // #743: per-server detail — which servers' writes actually timed out.
1791
+ // Absent when none did. `notifyWriteTimedOut` is the file-level "at
1792
+ // least one" summary.
1793
+ ...(notifyWriteTimedOutServerIds.length > 0 && {
1794
+ notifyWriteTimedOutServerIds,
1795
+ }),
1460
1796
  diagnosticsTimedOut,
1461
1797
  inconclusive,
1462
1798
  // R8 (#714): server ids of auxiliaries whose push wait was cut off by
@@ -2030,14 +2366,45 @@ export class LSPService {
2030
2366
  * Returns `performedWarmup: true` only when the round trip actually ran
2031
2367
  * (false = already warm, no-op) — tests assert on this to guard against
2032
2368
  * the warm-up becoming a mandatory extra round trip on every sweep.
2369
+ *
2370
+ * #744: a warm-up that TIMES OUT is no longer left as a silent dead end.
2371
+ * The old one-shot behavior left a wedged server (observed live: marksman,
2372
+ * a `workspaceIndexing` server, burned the full 20s and stayed cold) with
2373
+ * no re-warm and no skip — so every subsequent per-file touch in the sweep
2374
+ * re-paid a full per-file budget against it and timed out again, dragging
2375
+ * the whole sweep. Now: on a failed warm-up this retries exactly once (after
2376
+ * a short `warmupRetryBackoffMs` breather — the state where warm-up fails is
2377
+ * usually a server mid-relaunch/index that just needs a moment), and if the
2378
+ * retry ALSO leaves the server cold it is reported in `failedServerIds`. The
2379
+ * caller (the sweep loop) skips that server's files up front and marks them
2380
+ * unconfirmed, so per-file touches stop paying its timeout. This is a
2381
+ * sweep-scoped skip (the caller discards `failedServerIds` when the sweep
2382
+ * ends), deliberately NOT the global `broken` cooldown map: a server that is
2383
+ * merely still indexing is not broken and must not be cooldown-banned across
2384
+ * the whole session — it just wasn't ready for THIS sweep.
2385
+ *
2386
+ * "Failed warm-up" is measured per non-auxiliary server via the SAME
2387
+ * `demonstratedReady` signal `touchFile` marks on a confirmed round trip: a
2388
+ * server whose key is still absent from `demonstratedReady` after both
2389
+ * attempts never proved it can answer diagnostics. Warm-up stays
2390
+ * `clientScope:"primary"` (not the sweep's `"all"`) on purpose: `"all"`
2391
+ * would additionally spawn the sweep-EXCLUDED auxiliaries
2392
+ * (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`), and because `touchFile`'s
2393
+ * `inconclusive` flag is touch-wide, one slow advisory auxiliary would then
2394
+ * suppress the `demonstratedReady` marking for a perfectly healthy primary —
2395
+ * falsely condemning it. Recording per-primary-server outcomes and skipping
2396
+ * on those is the correct, non-regressing way to cover the servers the sweep
2397
+ * actually gates on (the sweep groups by primary server, so the group this
2398
+ * warms IS the one whose per-file touches would drag).
2033
2399
  */
2034
2400
  async ensureWarmForSweep(representativeFile, options = {}) {
2035
2401
  if (this.checkDestroyed() || options.signal?.aborted) {
2036
- return { performedWarmup: false };
2402
+ return { performedWarmup: false, failedServerIds: [] };
2037
2403
  }
2038
2404
  const servers = getServersForFileWithConfig(representativeFile).filter((s) => s.role !== "auxiliary");
2039
- if (servers.length === 0)
2040
- return { performedWarmup: false };
2405
+ if (servers.length === 0) {
2406
+ return { performedWarmup: false, failedServerIds: [] };
2407
+ }
2041
2408
  // A server with no resolvable root never spawns a client for this file
2042
2409
  // either way, so it can't block "already warm" — only servers that WILL
2043
2410
  // actually be used count toward the readiness check. Same key
@@ -2047,7 +2414,43 @@ export class LSPService {
2047
2414
  const keys = await Promise.all(servers.map((server) => this.demonstratedReadyKeyFor(server, representativeFile)));
2048
2415
  const alreadyWarm = keys.every((key) => key === undefined || this.state.demonstratedReady.has(key));
2049
2416
  if (alreadyWarm)
2050
- return { performedWarmup: false };
2417
+ return { performedWarmup: false, failedServerIds: [] };
2418
+ // #799: negative cache. Every server that still needs warming (not
2419
+ // already `demonstratedReady`) was ALSO left cold by a warm-up earlier
2420
+ // this session (`demonstratedCold`, populated below when a warm-up's
2421
+ // initial attempt + retry both fail) — skip straight to the group-skip
2422
+ // accounting the caller already has for `failedServerIds`, instead of
2423
+ // re-paying the initial-attempt + retry round trip all over again. A
2424
+ // MIXED group (one server cached cold, another never tried) still runs
2425
+ // the real warm-up — the never-tried server deserves its fair shot, and
2426
+ // `touchFile`'s multi-server spawn already covers both in one call.
2427
+ const cachedColdServerIds = [];
2428
+ let allNonWarmCached = true;
2429
+ for (let i = 0; i < servers.length; i++) {
2430
+ const key = keys[i];
2431
+ if (key === undefined || this.state.demonstratedReady.has(key))
2432
+ continue;
2433
+ if (this.state.demonstratedCold.has(key)) {
2434
+ cachedColdServerIds.push(servers[i].id);
2435
+ }
2436
+ else {
2437
+ allNonWarmCached = false;
2438
+ }
2439
+ }
2440
+ if (allNonWarmCached && cachedColdServerIds.length > 0) {
2441
+ logLatency({
2442
+ type: "phase",
2443
+ phase: "lsp_sweep_warmup_cached_cold",
2444
+ filePath: representativeFile,
2445
+ durationMs: 0,
2446
+ metadata: { serverIds: cachedColdServerIds },
2447
+ });
2448
+ return {
2449
+ performedWarmup: false,
2450
+ failedServerIds: cachedColdServerIds,
2451
+ skippedFromCache: true,
2452
+ };
2453
+ }
2051
2454
  let content;
2052
2455
  try {
2053
2456
  content = await nodeFs.promises.readFile(representativeFile, "utf-8");
@@ -2055,55 +2458,129 @@ export class LSPService {
2055
2458
  catch {
2056
2459
  // Nothing to warm up with — the real sweep's own read will surface
2057
2460
  // the file error.
2058
- return { performedWarmup: false };
2461
+ return { performedWarmup: false, failedServerIds: [] };
2462
+ }
2463
+ if (options.signal?.aborted) {
2464
+ return { performedWarmup: false, failedServerIds: [] };
2059
2465
  }
2060
- if (options.signal?.aborted)
2061
- return { performedWarmup: false };
2062
2466
  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(), {
2467
+ // A non-auxiliary server that WILL spawn for this file (resolvable key)
2468
+ // but whose key is still absent from `demonstratedReady` after an attempt
2469
+ // never proved it can answer diagnostics — that's a failed warm-up. A
2470
+ // server with an unresolvable key never spawns here, so it can't fail this
2471
+ // way and is excluded (never skipped by the caller for this file).
2472
+ const stillColdServerIds = () => {
2473
+ const cold = [];
2474
+ for (let i = 0; i < servers.length; i++) {
2475
+ const key = keys[i];
2476
+ if (key !== undefined && !this.state.demonstratedReady.has(key)) {
2477
+ cold.push(servers[i].id);
2478
+ }
2479
+ }
2480
+ return cold;
2481
+ };
2482
+ const runWarmupTouch = async (attempt) => {
2483
+ if (options.signal?.aborted)
2484
+ return;
2485
+ const startedAt = Date.now();
2486
+ logLatency({
2487
+ type: "phase",
2488
+ phase: "lsp_sweep_warmup_start",
2489
+ filePath: representativeFile,
2490
+ durationMs: 0,
2491
+ metadata: { serverIds: servers.map((s) => s.id), timeoutMs, attempt },
2492
+ });
2493
+ const warmupAttempt = this.touchFile(representativeFile, content, {
2494
+ diagnostics: "document",
2495
+ collectDiagnostics: false,
2496
+ clientScope: "primary",
2497
+ source: "lsp_sweep_warmup",
2498
+ maxClientWaitMs: timeoutMs,
2499
+ maxDiagnosticsWaitMs: timeoutMs,
2500
+ // #669: the caller's cap here is the warm-up budget for a genuinely
2501
+ // COLD server — it must act as a floor, not the usual ceiling, or
2502
+ // `perServerTimeout` silently shrinks it to the strategy's normal
2503
+ // warm-state `aggregateWaitMs` (e.g. 1000ms for typescript) instead
2504
+ // of the requested 20000ms.
2505
+ warmupOverride: true,
2506
+ // #799: only attempt 1 gets the cold-start floor — see the
2507
+ // `warmupAttempt` doc on `LSPTouchFileOptions`.
2508
+ warmupAttempt: attempt,
2509
+ });
2510
+ await (options.signal
2511
+ ? Promise.race([
2512
+ withDeadline(warmupAttempt, {
2513
+ ms: timeoutMs,
2514
+ onTimeout: "undefined",
2515
+ }),
2516
+ new Promise((resolve) => {
2517
+ if (options.signal.aborted) {
2518
+ resolve();
2519
+ return;
2520
+ }
2521
+ options.signal.addEventListener("abort", () => resolve(), {
2522
+ once: true,
2523
+ });
2524
+ }),
2525
+ ])
2526
+ : withDeadline(warmupAttempt, {
2527
+ ms: timeoutMs,
2528
+ onTimeout: "undefined",
2529
+ }));
2530
+ logLatency({
2531
+ type: "phase",
2532
+ phase: "lsp_sweep_warmup_done",
2533
+ filePath: representativeFile,
2534
+ durationMs: Date.now() - startedAt,
2535
+ metadata: {
2536
+ serverIds: servers.map((s) => s.id),
2537
+ timeoutMs,
2538
+ attempt,
2539
+ coldServerIds: stillColdServerIds(),
2540
+ },
2541
+ });
2542
+ };
2543
+ await runWarmupTouch(1);
2544
+ let failedServerIds = stillColdServerIds();
2545
+ // One retry, and only when the first attempt actually left a server cold —
2546
+ // a short backoff first so a server mid-relaunch/index gets a breather
2547
+ // rather than an immediate second hammer.
2548
+ if (failedServerIds.length > 0 && !options.signal?.aborted) {
2549
+ const backoffMs = warmupRetryBackoffMs();
2550
+ if (backoffMs > 0) {
2551
+ await new Promise((resolve) => {
2552
+ const timer = setTimeout(resolve, backoffMs);
2553
+ timer.unref?.();
2554
+ options.signal?.addEventListener("abort", () => resolve(), {
2094
2555
  once: true,
2095
2556
  });
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 };
2557
+ });
2558
+ }
2559
+ if (!options.signal?.aborted) {
2560
+ await runWarmupTouch(2);
2561
+ failedServerIds = stillColdServerIds();
2562
+ }
2563
+ }
2564
+ if (failedServerIds.length > 0) {
2565
+ logLatency({
2566
+ type: "phase",
2567
+ phase: "lsp_sweep_warmup_failed",
2568
+ filePath: representativeFile,
2569
+ durationMs: 0,
2570
+ metadata: { failedServerIds, timeoutMs },
2571
+ });
2572
+ // #799: record the negative cache so a LATER sweep this session
2573
+ // skips straight past re-paying this warm-up (initial + retry).
2574
+ // Cleared automatically the moment the server demonstrates
2575
+ // readiness through any path (`markDemonstratedReadyKey`).
2576
+ for (let i = 0; i < servers.length; i++) {
2577
+ const key = keys[i];
2578
+ if (key !== undefined && failedServerIds.includes(servers[i].id)) {
2579
+ this.state.demonstratedCold.add(key);
2580
+ }
2581
+ }
2582
+ }
2583
+ return { performedWarmup: true, failedServerIds };
2107
2584
  }
2108
2585
  /**
2109
2586
  * Actively scan every LSP-supported source file under a project root.
@@ -2252,6 +2729,8 @@ export class LSPService {
2252
2729
  // existing group-worker pool.
2253
2730
  const contentCache = new Map();
2254
2731
  const preOpenGroupFiles = async (groupFiles) => {
2732
+ if (isWarmAttached())
2733
+ return;
2255
2734
  for (const filePath of groupFiles) {
2256
2735
  if (signal?.aborted)
2257
2736
  return;
@@ -2333,20 +2812,28 @@ export class LSPService {
2333
2812
  // onTimeout:"undefined" so a hung file yields no diagnostics and the
2334
2813
  // worker moves on; a real touchFile rejection still propagates to the
2335
2814
  // 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" });
2815
+ const attached = isWarmAttached()
2816
+ ? await tryWarmAttachedDiagnostics(filePath, content, perFileMs, "sweep")
2817
+ : undefined;
2818
+ if (attached && !attached.available) {
2819
+ await this.ensureWarmForSweep(filePath, { signal });
2820
+ }
2821
+ const diagnostics = attached?.available
2822
+ ? attached.response.diagnostics
2823
+ : await withDeadline(this.touchFile(filePath, content, {
2824
+ diagnostics: "document",
2825
+ collectDiagnostics: true,
2826
+ clientScope: "all",
2827
+ source: "lens_diagnostics_full",
2828
+ // #584: opengrep's findings for a full sweep come from the
2829
+ // `opengrep-client.ts` CLI extractor (one project-wide scan,
2830
+ // cached, read via extractors.ts) instead — see the
2831
+ // `excludeServerIds` doc on `LSPTouchFileOptions`.
2832
+ excludeServerIds: WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS,
2833
+ // #645: lets a workspaceIndexing server (marksman) pay its
2834
+ // full wait budget only once across this whole sweep.
2835
+ sweepIndexGate,
2836
+ }), { ms: perFileMs, onTimeout: "undefined" });
2350
2837
  // #571: prefer #570's real per-touch inconclusive signal
2351
2838
  // (`touchFile`'s non-enumerable `.inconclusive` flag — set when the
2352
2839
  // notify write or the diagnostics wait itself timed out) over this
@@ -2426,7 +2913,9 @@ export class LSPService {
2426
2913
  if (signal?.aborted)
2427
2914
  return;
2428
2915
  // Fast path: one project-wide pull for the whole group (opt-in).
2429
- if (workspacePullEnabled && !group.multiServer) {
2916
+ if (!isWarmAttached() &&
2917
+ workspacePullEnabled &&
2918
+ !group.multiServer) {
2430
2919
  const pulled = await this.tryWorkspacePull(group.files, perFileMs);
2431
2920
  if (pulled) {
2432
2921
  for (const result of pulled) {
@@ -2457,10 +2946,48 @@ export class LSPService {
2457
2946
  // budget in one shot — the per-file "first N files eat individual
2458
2947
  // timeouts" failure mode this fixes doesn't apply there.
2459
2948
  const first = group.files[0];
2460
- if (first) {
2461
- await this.ensureWarmForSweep(first, { signal });
2949
+ if (first && !isWarmAttached()) {
2950
+ const warmup = await this.ensureWarmForSweep(first, { signal });
2462
2951
  if (signal?.aborted)
2463
2952
  return;
2953
+ // #744: the group's primary server failed warm-up (initial round
2954
+ // trip + one retry both left it cold). Every per-file touch to it
2955
+ // would re-pay its full timeout and time out again, dragging the
2956
+ // whole sweep — the exact wedged-marksman failure mode this closes.
2957
+ // So skip this group's files and record each as UNCONFIRMED
2958
+ // (timedOut + skippedWarmupFailure), never as confirmed-clean `[]`:
2959
+ // the group is keyed by its primary server, so a non-empty
2960
+ // `failedServerIds` means that primary is the one that couldn't warm.
2961
+ if (warmup.failedServerIds.length > 0) {
2962
+ logLatency({
2963
+ type: "phase",
2964
+ phase: "lsp_sweep_group_skipped_warmup",
2965
+ filePath: first,
2966
+ durationMs: 0,
2967
+ metadata: {
2968
+ failedServerIds: warmup.failedServerIds,
2969
+ // #799: distinguishes a fresh warm-up failure from a
2970
+ // negative-cache hit (this sweep never re-attempted warm-up
2971
+ // at all — it was already known cold from earlier this
2972
+ // session).
2973
+ skippedFromCache: warmup.skippedFromCache ?? false,
2974
+ skippedFiles: group.files.length,
2975
+ },
2976
+ });
2977
+ for (const filePath of group.files) {
2978
+ results.push({
2979
+ filePath,
2980
+ diagnostics: [],
2981
+ count: 0,
2982
+ timedOut: true,
2983
+ skippedWarmupFailure: true,
2984
+ });
2985
+ timedOutFiles += 1;
2986
+ completed += 1;
2987
+ }
2988
+ options.onProgress?.(completed, files.length);
2989
+ return;
2990
+ }
2464
2991
  }
2465
2992
  // #608/#621: batch-open a CHUNK of this group's files before
2466
2993
  // waiting on diagnostics for any of them individually — see
@@ -2575,6 +3102,7 @@ export class LSPService {
2575
3102
  catch {
2576
3103
  /* missing → will be pruned */
2577
3104
  }
3105
+ return true;
2578
3106
  }));
2579
3107
  client.pruneDiagnostics((filePath, ts) => !existingPaths.has(filePath) ||
2580
3108
  now - ts > CASCADE_DIAGNOSTICS_TTL_MS);
@@ -2734,8 +3262,6 @@ export function resetLSPService(options = {}) {
2734
3262
  * event-loop occupancy can be guarded (see workspace-diagnostics-occupancy
2735
3263
  * test). Not part of the public API.
2736
3264
  */
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);
3265
+ export function __collectWorkspaceDiagnosticFilesForTest(root, maxFiles, signal, homeDir) {
3266
+ return collectWorkspaceDiagnosticFiles(path.resolve(root), maxFiles ?? getMaxWorkspaceDiagnosticFiles(), signal, homeDir);
2741
3267
  }