pi-lens 4.1.2 → 4.1.4

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 (359) hide show
  1. package/CHANGELOG.md +1308 -0
  2. package/README.md +3 -0
  3. package/config/biome/core.jsonc +11 -2
  4. package/config/dependency-cruiser-eager-allowlist.json +91 -0
  5. package/dist/clients/actionable-warnings-logger.js +18 -3
  6. package/dist/clients/actionable-warnings.js +927 -80
  7. package/dist/clients/agent-behavior-client.js +13 -4
  8. package/dist/clients/ast-grep-client.js +136 -9
  9. package/dist/clients/ast-grep-rule-manager.js +60 -5
  10. package/dist/clients/ast-grep-tool-logger.js +2 -2
  11. package/dist/clients/availability-probe-flight.js +12 -0
  12. package/dist/clients/bash-file-access.js +1 -3
  13. package/dist/clients/biome-client.js +16 -2
  14. package/dist/clients/blocker-freshness.js +34 -0
  15. package/dist/clients/bootstrap.js +509 -73
  16. package/dist/clients/bounded-cache.js +152 -12
  17. package/dist/clients/bounded-telemetry.js +115 -23
  18. package/dist/clients/build-identity.js +95 -0
  19. package/dist/clients/bundled-resource-health.js +113 -0
  20. package/dist/clients/bus-events-logger.js +2 -2
  21. package/dist/clients/bus-publish.js +3 -0
  22. package/dist/clients/cache/rule-cache.js +33 -5
  23. package/dist/clients/cache-manager.js +105 -6
  24. package/dist/clients/cache-observability.js +46 -19
  25. package/dist/clients/cargo-manifest.js +422 -0
  26. package/dist/clients/cascade-logger.js +17 -3
  27. package/dist/clients/code-quality-warnings.js +13 -3
  28. package/dist/clients/complexity-client.js +16 -5
  29. package/dist/clients/config-core/deny.js +221 -0
  30. package/dist/clients/config-core/index.js +47 -0
  31. package/dist/clients/config-core/merge.js +357 -0
  32. package/dist/clients/config-core/normalize.js +340 -0
  33. package/dist/clients/config-core/process-spec.js +248 -0
  34. package/dist/clients/config-core/provenance.js +171 -0
  35. package/dist/clients/config-core/records.js +218 -0
  36. package/dist/clients/config-core/resolve.js +125 -0
  37. package/dist/clients/config-core/safe-object.js +78 -0
  38. package/dist/clients/config-core/schema.js +167 -0
  39. package/dist/clients/config-diagnostic-codes.js +281 -0
  40. package/dist/clients/config-locations.js +160 -0
  41. package/dist/clients/config-resolve.js +789 -0
  42. package/dist/clients/config-schema.js +197 -0
  43. package/dist/clients/config-warn.js +407 -0
  44. package/dist/clients/dead-code-client.js +33 -25
  45. package/dist/clients/dead-code-logger.js +2 -2
  46. package/dist/clients/deadline-utils.js +178 -0
  47. package/dist/clients/debug-handles.js +2 -2
  48. package/dist/clients/debug-heap.js +3 -3
  49. package/dist/clients/deferred-lsp-work.js +106 -0
  50. package/dist/clients/degradation-ledger.js +166 -8
  51. package/dist/clients/demoted-finding-render.js +14 -0
  52. package/dist/clients/dependency-checker.js +28 -22
  53. package/dist/clients/deps/minimatch.js +1 -1
  54. package/dist/clients/diagnostic-line-freshness.js +12 -9
  55. package/dist/clients/diagnostic-logger.js +14 -4
  56. package/dist/clients/dispatch/collect-later-tier.js +39 -0
  57. package/dist/clients/dispatch/dispatcher.js +304 -19
  58. package/dist/clients/dispatch/fact-store.js +328 -9
  59. package/dist/clients/dispatch/integration.js +214 -107
  60. package/dist/clients/dispatch/pending-runner-findings.js +87 -0
  61. package/dist/clients/dispatch/plan.js +21 -5
  62. package/dist/clients/dispatch/runners/actionlint.js +0 -1
  63. package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
  64. package/dist/clients/dispatch/runners/biome-check.js +0 -1
  65. package/dist/clients/dispatch/runners/cpp-check.js +0 -1
  66. package/dist/clients/dispatch/runners/credo.js +0 -1
  67. package/dist/clients/dispatch/runners/cue-vet.js +0 -1
  68. package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  69. package/dist/clients/dispatch/runners/detekt.js +0 -1
  70. package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  71. package/dist/clients/dispatch/runners/elixir-check.js +0 -1
  72. package/dist/clients/dispatch/runners/eslint.js +5 -2
  73. package/dist/clients/dispatch/runners/fact-rules.js +0 -1
  74. package/dist/clients/dispatch/runners/fish-indent.js +0 -1
  75. package/dist/clients/dispatch/runners/gleam-check.js +0 -1
  76. package/dist/clients/dispatch/runners/go-vet.js +1 -3
  77. package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  78. package/dist/clients/dispatch/runners/hadolint.js +0 -1
  79. package/dist/clients/dispatch/runners/helm-lint.js +16 -4
  80. package/dist/clients/dispatch/runners/helm-render.js +54 -10
  81. package/dist/clients/dispatch/runners/htmlhint.js +0 -1
  82. package/dist/clients/dispatch/runners/index.js +1 -3
  83. package/dist/clients/dispatch/runners/javac.js +0 -1
  84. package/dist/clients/dispatch/runners/ktlint.js +0 -1
  85. package/dist/clients/dispatch/runners/lsp.js +37 -2
  86. package/dist/clients/dispatch/runners/markdownlint.js +4 -2
  87. package/dist/clients/dispatch/runners/mypy.js +0 -1
  88. package/dist/clients/dispatch/runners/oxlint.js +24 -22
  89. package/dist/clients/dispatch/runners/php-lint.js +0 -1
  90. package/dist/clients/dispatch/runners/phpstan.js +0 -1
  91. package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  92. package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
  93. package/dist/clients/dispatch/runners/pyright.js +7 -4
  94. package/dist/clients/dispatch/runners/rubocop.js +0 -1
  95. package/dist/clients/dispatch/runners/ruff.js +0 -1
  96. package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
  97. package/dist/clients/dispatch/runners/shellcheck.js +0 -1
  98. package/dist/clients/dispatch/runners/shfmt.js +0 -1
  99. package/dist/clients/dispatch/runners/spellcheck.js +0 -1
  100. package/dist/clients/dispatch/runners/spotbugs.js +0 -1
  101. package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  102. package/dist/clients/dispatch/runners/stylelint.js +0 -1
  103. package/dist/clients/dispatch/runners/swiftlint.js +0 -1
  104. package/dist/clients/dispatch/runners/taplo.js +0 -1
  105. package/dist/clients/dispatch/runners/terragrunt.js +0 -1
  106. package/dist/clients/dispatch/runners/tflint.js +0 -1
  107. package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  108. package/dist/clients/dispatch/runners/trivy-config.js +0 -1
  109. package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  110. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
  111. package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
  112. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  113. package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  114. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
  115. package/dist/clients/dispatch/runners/vale.js +0 -1
  116. package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  117. package/dist/clients/dispatch/runners/yamllint.js +0 -1
  118. package/dist/clients/dispatch/runners/zig-check.js +0 -1
  119. package/dist/clients/disposition-logger.js +3 -3
  120. package/dist/clients/effective-config.js +403 -0
  121. package/dist/clients/error-class.js +23 -0
  122. package/dist/clients/event-loop-hold.js +274 -0
  123. package/dist/clients/event-loop-monitor.js +66 -1
  124. package/dist/clients/extension-log.js +2 -2
  125. package/dist/clients/feature-hints.js +2 -1
  126. package/dist/clients/file-time.js +6 -1
  127. package/dist/clients/file-utils.js +266 -13
  128. package/dist/clients/finding-delivery-gate.js +55 -13
  129. package/dist/clients/format-service.js +6 -1
  130. package/dist/clients/formatters.js +303 -104
  131. package/dist/clients/freshness-cadence.js +17 -0
  132. package/dist/clients/generated-artifacts.js +114 -19
  133. package/dist/clients/generation-guard.js +4 -9
  134. package/dist/clients/git-guard.js +198 -48
  135. package/dist/clients/git-tracked-ignore.js +32 -1
  136. package/dist/clients/go-client.js +39 -0
  137. package/dist/clients/govulncheck-client.js +5 -1
  138. package/dist/clients/gradle-ktfmt-style.js +252 -0
  139. package/dist/clients/gzip-stage-write.js +7 -0
  140. package/dist/clients/hashline-anchor.js +424 -0
  141. package/dist/clients/hook-budgets.js +76 -0
  142. package/dist/clients/host-edit-normalize.js +5 -2
  143. package/dist/clients/host-ports.js +1 -1
  144. package/dist/clients/installer/index.js +200 -84
  145. package/dist/clients/installer/managed-tool-refresh.js +3 -1
  146. package/dist/clients/instance-reaper.js +108 -243
  147. package/dist/clients/instance-registry-lock.js +145 -0
  148. package/dist/clients/instance-registry.js +435 -122
  149. package/dist/clients/jscpd-client.js +6 -1
  150. package/dist/clients/language-profile.js +4 -2
  151. package/dist/clients/language-registry.js +595 -0
  152. package/dist/clients/latency-logger.js +179 -3
  153. package/dist/clients/ledger-bounds.js +13 -0
  154. package/dist/clients/lens-config.js +141 -30
  155. package/dist/clients/lens-engine.js +30 -37
  156. package/dist/clients/lens-flag-registry.js +67 -6
  157. package/dist/clients/lens-map.js +3 -1
  158. package/dist/clients/log-cleanup.js +2 -2
  159. package/dist/clients/lsp/aggregation.js +3 -1
  160. package/dist/clients/lsp/cascade-tier.js +4 -0
  161. package/dist/clients/lsp/client.js +672 -49
  162. package/dist/clients/lsp/config.js +504 -156
  163. package/dist/clients/lsp/diagnostic-binding.js +10 -5
  164. package/dist/clients/lsp/edits.js +15 -2
  165. package/dist/clients/lsp/index.js +1179 -247
  166. package/dist/clients/lsp/jvm-runtime.js +5 -0
  167. package/dist/clients/lsp/language.js +18 -169
  168. package/dist/clients/lsp/launch.js +38 -9
  169. package/dist/clients/lsp/pending-aux-coverage.js +113 -12
  170. package/dist/clients/lsp/server.js +426 -160
  171. package/dist/clients/lsp/session-roots.js +192 -0
  172. package/dist/clients/lsp/spawn-history.js +2 -7
  173. package/dist/clients/lsp/tsserver-sync.js +9 -2
  174. package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
  175. package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  176. package/dist/clients/lsp-mutation.js +179 -23
  177. package/dist/clients/map-with-concurrency.js +36 -0
  178. package/dist/clients/mcp/analyze.js +45 -10
  179. package/dist/clients/mcp/session.js +19 -17
  180. package/dist/clients/memory-sampler.js +28 -1
  181. package/dist/clients/message-end-attribution.js +30 -0
  182. package/dist/clients/metrics-history.js +21 -4
  183. package/dist/clients/middle-man-analysis.js +2 -4
  184. package/dist/clients/module-report.js +20 -45
  185. package/dist/clients/mutating-tool.js +651 -0
  186. package/dist/clients/mutation-attribution.js +368 -0
  187. package/dist/clients/mutation-bridge.js +240 -0
  188. package/dist/clients/ndjson-logger.js +247 -20
  189. package/dist/clients/observed-mutation-sources.js +101 -0
  190. package/dist/clients/observed-mutation.js +1215 -0
  191. package/dist/clients/opaque-mutation-scan.js +300 -40
  192. package/dist/clients/opengrep-config.js +4 -0
  193. package/dist/clients/package-manager.js +201 -44
  194. package/dist/clients/partial-edit-apply.js +359 -80
  195. package/dist/clients/path-attribution-telemetry.js +23 -7
  196. package/dist/clients/path-keyed-map.js +35 -3
  197. package/dist/clients/path-utils.js +460 -13
  198. package/dist/clients/persist-debounce.js +8 -1
  199. package/dist/clients/php-cs-fixer-config.js +114 -0
  200. package/dist/clients/pipeline.js +77 -24
  201. package/dist/clients/probe-home-state.js +228 -0
  202. package/dist/clients/process-bridge.js +66 -0
  203. package/dist/clients/process-singletons.js +210 -0
  204. package/dist/clients/process-snapshot.js +68 -0
  205. package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  206. package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  207. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
  208. package/dist/clients/project-diagnostics/scanner.js +61 -24
  209. package/dist/clients/project-lens-config.js +393 -122
  210. package/dist/clients/project-snapshot.js +85 -35
  211. package/dist/clients/python-environment.js +217 -0
  212. package/dist/clients/python-provenance.js +639 -0
  213. package/dist/clients/quiet-window.js +6 -1
  214. package/dist/clients/read-bridge.js +13 -21
  215. package/dist/clients/read-expansion.js +9 -46
  216. package/dist/clients/read-guard-logger.js +39 -6
  217. package/dist/clients/read-guard-tool-lines.js +364 -212
  218. package/dist/clients/read-guard.js +95 -7
  219. package/dist/clients/recent-touches.js +17 -5
  220. package/dist/clients/resource-sampler.js +195 -96
  221. package/dist/clients/review-graph/builder.js +752 -132
  222. package/dist/clients/review-graph/service.js +55 -3
  223. package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  224. package/dist/clients/review-graph/tsconfig-paths.js +5 -1
  225. package/dist/clients/review-graph/workspace-modules.js +10 -74
  226. package/dist/clients/review-graph-logger.js +30 -4
  227. package/dist/clients/runtime-agent-end.js +18 -2
  228. package/dist/clients/runtime-context.js +49 -16
  229. package/dist/clients/runtime-coordinator.js +93 -6
  230. package/dist/clients/runtime-session.js +169 -34
  231. package/dist/clients/runtime-tool-call.js +183 -35
  232. package/dist/clients/runtime-tool-result.js +894 -241
  233. package/dist/clients/runtime-turn.js +1264 -74
  234. package/dist/clients/rust-client.js +31 -0
  235. package/dist/clients/safe-spawn.js +191 -28
  236. package/dist/clients/sanitize.js +7 -1
  237. package/dist/clients/scratch-tree-policy.js +1 -3
  238. package/dist/clients/security-scan-client.js +51 -17
  239. package/dist/clients/session-lifecycle.js +238 -42
  240. package/dist/clients/session-start-observability.js +79 -0
  241. package/dist/clients/sessionstart-logger.js +12 -3
  242. package/dist/clients/sg-runner.js +27 -15
  243. package/dist/clients/sgconfig.js +6 -1
  244. package/dist/clients/shared-checkout-guard.js +338 -0
  245. package/dist/clients/skills-resolver.js +105 -0
  246. package/dist/clients/smells-rollup.js +2 -2
  247. package/dist/clients/spawn-output-cap.js +37 -0
  248. package/dist/clients/spawn-timeout-cooldown.js +15 -1
  249. package/dist/clients/startup-scan.js +6 -7
  250. package/dist/clients/startup-timing.js +69 -4
  251. package/dist/clients/string-utils.js +33 -0
  252. package/dist/clients/subagent-mode.js +17 -4
  253. package/dist/clients/test-runner-client.js +586 -118
  254. package/dist/clients/test-runner-delivery.js +239 -0
  255. package/dist/clients/tool-definition.js +41 -1
  256. package/dist/clients/tool-policy.js +92 -22
  257. package/dist/clients/tree-sitter-cache.js +40 -40
  258. package/dist/clients/tree-sitter-client.js +97 -53
  259. package/dist/clients/tree-sitter-logger.js +19 -3
  260. package/dist/clients/tree-sitter-query-loader.js +81 -1
  261. package/dist/clients/tree-sitter-shared.js +51 -46
  262. package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  263. package/dist/clients/typos-config.js +5 -0
  264. package/dist/clients/user-notify.js +6 -2
  265. package/dist/clients/warm-attach.js +8 -10
  266. package/dist/clients/widget-state.js +191 -26
  267. package/dist/clients/word-index-logger.js +15 -3
  268. package/dist/clients/word-index-store.js +463 -0
  269. package/dist/clients/word-index.js +815 -125
  270. package/dist/clients/workspace-topology.js +25 -1
  271. package/dist/clients/zizmor-config.js +29 -6
  272. package/dist/index.js +68577 -53950
  273. package/dist/mcp/analyze-cli.js +3 -1
  274. package/dist/mcp/build-staleness.js +8 -7
  275. package/dist/mcp/cli.js +3 -1
  276. package/dist/mcp/server.js +133 -6
  277. package/dist/scripts/lib/process-scan.mjs +583 -0
  278. package/dist/scripts/lib/skills-predicate.mjs +129 -0
  279. package/dist/tools/effective-config.js +89 -0
  280. package/dist/tools/lens-diagnostics.js +99 -24
  281. package/dist/tools/lsp-diagnostics.js +81 -85
  282. package/dist/tools/lsp-navigation.js +40 -17
  283. package/dist/tools/shared.js +0 -1
  284. package/docs/agent-guide.md +68 -3
  285. package/docs/ast-grep_rules_catalog.md +41 -10
  286. package/docs/astplayground.md +26 -17
  287. package/docs/configuration.md +222 -0
  288. package/docs/dependencies.md +3 -3
  289. package/docs/features.md +44 -5
  290. package/docs/language-coverage.md +2 -2
  291. package/docs/pi-lens-fixer.md +25 -0
  292. package/docs/pi-lens-investigator.md +25 -0
  293. package/docs/pi-lens-reviewer.md +27 -0
  294. package/docs/pi-lens-subagent.md +38 -0
  295. package/docs/pi-lens-warden.md +55 -0
  296. package/docs/public-api-stability.md +359 -0
  297. package/docs/release-qa-baseline.md +182 -0
  298. package/docs/subagent-compat.md +110 -30
  299. package/docs/tree-sitter_rules_catalog.md +2 -2
  300. package/package.json +17 -21
  301. package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  302. package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  303. package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  304. package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  305. package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  306. package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  307. package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  308. package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  309. package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  310. package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  311. package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  312. package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  313. package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  314. package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  315. package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  316. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  317. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  318. package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  319. package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  320. package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  321. package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  322. package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  323. package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  324. package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  325. package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  326. package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  327. package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  328. package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  329. package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  330. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  331. package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  332. package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  333. package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  334. package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  335. package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  336. package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  337. package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  338. package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  339. package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  340. package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  341. package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  342. package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  343. package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  344. package/rules/ast-grep-rules/rules/no-important.yml +21 -0
  345. package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  346. package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  347. package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  348. package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  349. package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  350. package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  351. package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  352. package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  353. package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  354. package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  355. package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  356. package/scripts/analyze-pi-lens-logs.mjs +192 -1
  357. package/scripts/install-selftest.mjs +58 -2
  358. package/scripts/lib/skills-predicate.mjs +129 -0
  359. package/scripts/rpc-load-check.mjs +3 -1
@@ -12,27 +12,36 @@ import * as nodeFs from "node:fs";
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import { pathToFileURL, URL } from "node:url";
15
+ import { BoundedFifoMap } from "../bounded-cache.js";
15
16
  import { getProjectIgnoreMatcher, isExcludedDirName } from "../file-utils.js";
16
17
  import { recordLsp } from "../widget-state.js";
17
18
  import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
18
19
  import { detectFileRole } from "../file-role.js";
19
20
  import { emitBounded } from "../bounded-telemetry.js";
20
- import { logLatency } from "../latency-logger.js";
21
+ import { logLatency, phaseFinished, phaseStarted } from "../latency-logger.js";
21
22
  import { logSessionStart } from "../sessionstart-logger.js";
22
23
  import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "../degradation-ledger.js";
23
24
  import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
24
25
  import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
25
- import { withDeadline, withTimeout } from "../deadline-utils.js";
26
+ import { sampleProcessTreeCpuPercent } from "../resource-sampler.js";
27
+ import { bounded, withDeadline, withTimeout } from "../deadline-utils.js";
28
+ import { getAmbientAbortSignal } from "../safe-spawn.js";
29
+ import { abortDeferredLspWork } from "../deferred-lsp-work.js";
26
30
  import { acquireWorkspaceSweepHold, clearWorkspaceSweepHoldForSessionStart, } from "./workspace-sweep-hold.js";
27
31
  import { DocumentDriftTracker, fingerprintDocumentContent, } from "./document-drift.js";
28
- import { markPendingAuxiliaryCoverage } from "./pending-aux-coverage.js";
32
+ import { markPendingAuxiliaryCoverage, napiFallbackCoveredSince, } from "./pending-aux-coverage.js";
29
33
  import { isAtOrAboveHomeDir, isWindowsPath, normalizeMapKey, uriToPath, } from "../path-utils.js";
30
34
  import { recordLspMutation } from "../lsp-mutation.js";
31
35
  import { createLSPClient } from "./client.js";
32
36
  import { auxiliaryCoverageGap, bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, hashDiagnosticContent, resolveTouchVerdict, touchCoverageGap, } from "./diagnostic-binding.js";
33
37
  import { getServersForFileWithConfig, getServerInitOverride, } from "./config.js";
38
+ // #2052: deliberately NOT taken from `config.js`. This module's import surface
39
+ // from `config.js` is mirrored by explicit `vi.mock` factories in ~58 test
40
+ // files, so every new symbol taken from there breaks all of them.
41
+ import { getSessionRootsForTelemetry, isOutsideAllSessionRoots, } from "./session-roots.js";
42
+ import { getProcessSingleton } from "../process-singletons.js";
34
43
  import { getLanguageId } from "./language.js";
35
- import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, } from "./server.js";
44
+ import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, resetLspLaunchAvailabilityGeneration, } from "./server.js";
36
45
  import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
37
46
  const WORKSPACE_ATTRIBUTION_CLIENT_CAP = 16;
38
47
  import { raceToCompletion } from "./aggregation.js";
@@ -301,6 +310,19 @@ function readEnvAuxGraceMs() {
301
310
  return undefined;
302
311
  return parsed;
303
312
  }
313
+ const DEFAULT_AUX_GRACE_CEILING_MS = 2000;
314
+ const MAX_ADAPTIVE_AUX_GRACE_CEILING_MS = 8000;
315
+ const ADAPTIVE_AUX_GRACE_MARGIN_MS = 500;
316
+ export function auxWaitBudgetMs(serverId, isCold, configuredCeilingMs, declaredWaitMs) {
317
+ if (configuredCeilingMs !== undefined || !isCold) {
318
+ return Math.min(declaredWaitMs, configuredCeilingMs ?? DEFAULT_AUX_GRACE_CEILING_MS);
319
+ }
320
+ const observedSpawnMs = getSuccessfulLspSpawnDurationMs(serverId);
321
+ if (observedSpawnMs === undefined || observedSpawnMs <= 0) {
322
+ return Math.min(declaredWaitMs, DEFAULT_AUX_GRACE_CEILING_MS);
323
+ }
324
+ return Math.min(MAX_ADAPTIVE_AUX_GRACE_CEILING_MS, Math.max(declaredWaitMs, observedSpawnMs + ADAPTIVE_AUX_GRACE_MARGIN_MS));
325
+ }
304
326
  const DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_THRESHOLD_MS ?? "250", 10) || 250);
305
327
  const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_SETTLE_MS ?? "400", 10) || 400);
306
328
  // Once the fastest client has diagnostics, remaining clients get this window before
@@ -450,6 +472,19 @@ function notifyWriteBudgetMs() {
450
472
  const raw = Number(process.env.PI_LENS_LSP_NOTIFY_BUDGET_MS);
451
473
  return Number.isFinite(raw) && raw > 0 ? raw : 2000;
452
474
  }
475
+ // #2239: the SAME effective per-server wait floor `getClientForFile` uses to
476
+ // size its own acquisition race — the caller's declared budget, raised to
477
+ // whatever the file's primary server(s) configure via `clientWaitTimeoutMs`
478
+ // (Ruby 30s, and the Bash/JSON/Vue/Svelte/Prisma overrides #2233 added). A
479
+ // cold primary spawn is allowed to run this long, so anything downstream that
480
+ // charges itself against `maxWaitMs` alone — rather than this floor — sees an
481
+ // already-elapsed time it never budgeted for and clamps to zero.
482
+ function primaryServerWaitFloorMs(filePath, maxWaitMs) {
483
+ const serverWaitOverrideMs = getServersForFileWithConfig(filePath)
484
+ .filter((s) => s.role !== "auxiliary")
485
+ .reduce((max, server) => Math.max(max, server.clientWaitTimeoutMs ?? 0), 0);
486
+ return Math.max(maxWaitMs ?? 0, serverWaitOverrideMs);
487
+ }
453
488
  // #1459: how long ONE auxiliary notify write may stay outstanding before the
454
489
  // server counts as wedged rather than merely slow. A scanner whose per-file work
455
490
  // exceeds the write budget is normal (opengrep routinely needs >2s on a large
@@ -461,6 +496,30 @@ const NOTIFY_WEDGED_BUDGET_MULTIPLIER = 5;
461
496
  function notifyWedgedMs() {
462
497
  return notifyWriteBudgetMs() * NOTIFY_WEDGED_BUDGET_MULTIPLIER;
463
498
  }
499
+ // #2358: the wedge window's teardown decision gained a liveness discriminator.
500
+ // The fixed window above stays the FLOOR; a server whose drain history says it
501
+ // answers per-write in `ewmaMs` with `unacked` documents queued earns
502
+ // `k x ewmaMs x unacked` of patience instead — the issue's "a server that
503
+ // historically answers in 855 ms with 8 writes queued earns 15-20 s". The cap
504
+ // bounds the whole thing so a busy-but-never-draining server still respawns.
505
+ const NOTIFY_WEDGED_EWMA_MULTIPLIER = 2;
506
+ const NOTIFY_WEDGED_CAP_MS = 60_000;
507
+ function notifyWedgedCapMs() {
508
+ const raw = Number(process.env.PI_LENS_LSP_NOTIFY_WEDGED_CAP_MS);
509
+ return Number.isFinite(raw) && raw > 0 ? raw : NOTIFY_WEDGED_CAP_MS;
510
+ }
511
+ // #2358: how long the CPU-liveness discriminator watches the server between
512
+ // two process-CPU samples before deciding "busy" vs "flat". Only ever spent
513
+ // on a server whose write is already past its patience window — never on the
514
+ // per-edit hot path.
515
+ function notifyStallCpuSampleMs() {
516
+ const raw = Number(process.env.PI_LENS_LSP_NOTIFY_STALL_CPU_SAMPLE_MS);
517
+ return Number.isFinite(raw) && raw > 0 ? raw : 1200;
518
+ }
519
+ // A server burning more than this percent of one core across the sample
520
+ // window counts as BUSY (progressing), not flat (wedged). A single-threaded
521
+ // scanner under burst sits near 100; an idle or dead one near 0.
522
+ const NOTIFY_STALL_CPU_BUSY_FLOOR_PERCENT = 10;
464
523
  // #1714: how many document notifies one auxiliary may hold UNACKNOWLEDGED
465
524
  // before the next notify has to prove the server drained its input.
466
525
  //
@@ -576,10 +635,12 @@ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceD
576
635
  }
577
636
  // --- Service ---
578
637
  export class LSPService {
638
+ sessionCwd;
579
639
  state;
580
640
  workspaceProbeLogged = new Set();
581
641
  /** Per-service immutable root-boundary verdicts; root detectors cache hits too. */
582
642
  projectBoundaryCache = new Map();
643
+ /** Foreign roots are recorded once per normalized root for this service/session. */
583
644
  warmStartLogged = new Set();
584
645
  optionalFailureLogged = new Set();
585
646
  /** Server/root pairs that already emitted unavailable for the current occurrence. */
@@ -692,6 +753,22 @@ export class LSPService {
692
753
  * successful write clears its entry.
693
754
  */
694
755
  notifyWriteBackpressureStreak = new Map();
756
+ /**
757
+ * #2358: EWMA of one auxiliary's per-document drain latency
758
+ * (ms per unacknowledged write), keyed by "serverId:normalizedRoot".
759
+ *
760
+ * Updated from DRAINED notify barriers only: the round-trip proves the
761
+ * server processed its `outstanding` backlog, and
762
+ * duration / outstanding is the per-write service time a single-threaded
763
+ * scanner actually achieves under load. The breaker grants a wedged write
764
+ * `k x ewma x unacked` of patience from it, so a slow-but-alive scanner is
765
+ * not killed at the fixed window by construction (#2358).
766
+ *
767
+ * Cleared by the service teardown (via `session_start`) like the rest of
768
+ * the breaker state; a stale throughput estimate from a previous session's
769
+ * client must not price the next one.
770
+ */
771
+ auxNotifyDrainLatencyEwma = new Map();
695
772
  /**
696
773
  * #1714: unacknowledged auxiliary document notifies per server key
697
774
  * ("serverId:normalizedRoot" — the same identity as every other gate here).
@@ -728,6 +805,15 @@ export class LSPService {
728
805
  * never lands is still demoted (see {@link demoteForNotifyStall}).
729
806
  */
730
807
  outstandingAuxNotifyWrites = new Map();
808
+ /**
809
+ * #2356: auxiliary clients removed by the notify-stall breaker are a
810
+ * temporary absence, not a missing scanner. Keep the demotion identity after
811
+ * deleting the client so the turn-end late-coverage probe can re-arm the
812
+ * pending pair until a replacement is published (or its existing bounded
813
+ * re-arm ceiling is reached). A successful replacement removes the marker.
814
+ */
815
+ static MAX_NOTIFY_STALL_DEMOTIONS = 50;
816
+ notifyStallDemotions = new BoundedFifoMap(LSPService.MAX_NOTIFY_STALL_DEMOTIONS);
731
817
  /** LRU clock for capacity eviction, keyed by the canonical server/root key. */
732
818
  clientLastUsedAt = new Map();
733
819
  /**
@@ -755,8 +841,9 @@ export class LSPService {
755
841
  * a permanent promise/microtask tax.
756
842
  */
757
843
  generationHandoff;
758
- constructor(generationHandoff) {
844
+ constructor(generationHandoff, sessionCwd) {
759
845
  this.generationHandoff = generationHandoff;
846
+ this.sessionCwd = sessionCwd;
760
847
  this.state = {
761
848
  clients: new Map(),
762
849
  servers: new Map(),
@@ -776,6 +863,14 @@ export class LSPService {
776
863
  const candidate = await server.root(filePath);
777
864
  if (!candidate)
778
865
  return undefined;
866
+ // #2052: a file outside EVERY initialized session cwd gets no client at
867
+ // all. The ceiling below is unchanged (`process.cwd()`, as before this
868
+ // issue) and still only clamps files that are actually inside it —
869
+ // `enforceLspRootCeiling` returns the root untouched for an out-of-ceiling
870
+ // file. So this gate is the ONLY new refusal, and it consults the
871
+ // registry, not a single cwd.
872
+ if (isOutsideAllSessionRoots(filePath))
873
+ return undefined;
779
874
  const root = enforceLspRootCeiling(candidate, process.cwd(), filePath);
780
875
  if (normalizeMapKey(root) === normalizeMapKey(process.cwd()))
781
876
  return root;
@@ -919,6 +1014,7 @@ export class LSPService {
919
1014
  return false;
920
1015
  }
921
1016
  const [victimKey, victimClient] = victim;
1017
+ this.releaseOutstandingAuxNotifyWrite(victimKey);
922
1018
  await victimClient.shutdown({ reason: "client_ceiling_lru" });
923
1019
  this.state.clients.delete(victimKey);
924
1020
  this.state.clientSpawnedAt.delete(victimKey);
@@ -935,6 +1031,15 @@ export class LSPService {
935
1031
  clearTimeout(timer);
936
1032
  this.typeScriptIdleTimers.delete(key);
937
1033
  }
1034
+ /** Release a notify token and its timer when its client generation retires. */
1035
+ releaseOutstandingAuxNotifyWrite(key, token) {
1036
+ const current = this.outstandingAuxNotifyWrites.get(key);
1037
+ if (!current || (token !== undefined && current !== token))
1038
+ return;
1039
+ clearTimeout(current.wedgeTimer);
1040
+ this.outstandingAuxNotifyWrites.delete(key);
1041
+ current.resolveSettled();
1042
+ }
938
1043
  scheduleTypeScriptIdleEviction(key) {
939
1044
  if (!key.startsWith("typescript:"))
940
1045
  return;
@@ -960,6 +1065,7 @@ export class LSPService {
960
1065
  // Publish the cold state synchronously before awaiting teardown. A request
961
1066
  // arriving while shutdown is in progress therefore waits on the spawn gate
962
1067
  // and creates a fresh client; it can never receive this retiring one.
1068
+ this.releaseOutstandingAuxNotifyWrite(key);
963
1069
  this.state.clients.delete(key);
964
1070
  this.state.clientSpawnedAt.delete(key);
965
1071
  this.state.demonstratedReady.delete(key);
@@ -1101,23 +1207,130 @@ export class LSPService {
1101
1207
  return;
1102
1208
  }
1103
1209
  this.notifyWriteBackpressureStreak.delete(key);
1104
- this.demoteForNotifyStall(key, entry, filePath, {
1210
+ // #2358: the consecutive-timeout ladder lands here too, and its verdict
1211
+ // must not kill a server that is demonstrably BUSY. The guarded demotion
1212
+ // samples the live process CPU and merely resets the ladder (the next
1213
+ // three timeouts re-try) when the server is progressing — the wedged-write
1214
+ // path's cap owns a busy-but-never-draining server, not this streak.
1215
+ void this.demoteNotifyStallCpuGuarded(key, entry, filePath, {
1105
1216
  consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
1106
1217
  });
1107
1218
  }
1219
+ /**
1220
+ * #2358: liveness-guarded teardown for the STREAK ladder.
1221
+ *
1222
+ * The wedged-write path ({@link decideNotifyStallTeardown}) keeps its own
1223
+ * re-arm loop so a busy server's outstanding write stays un-torn-down; the
1224
+ * streak ladder has no write to hold — it simply does not demote, and the
1225
+ * next three timeouts re-attempt. Generation-guarded after the await so a
1226
+ * client replaced mid-sample is never demoted for a verdict about its
1227
+ * predecessor.
1228
+ */
1229
+ async demoteNotifyStallCpuGuarded(key, entry, filePath, reason) {
1230
+ if (this.state.clients.get(key) !== entry.client)
1231
+ return;
1232
+ // The CPU verdict is asynchronous, but the streak has already committed to
1233
+ // this client's teardown path. Release the TypeScript idle-timer ownership
1234
+ // before sampling so another idle callback cannot target the same client
1235
+ // while the verdict is in flight. A BUSY verdict below re-arms a fresh timer.
1236
+ this.clearTypeScriptIdleTimer(key);
1237
+ const verdict = await this.notifyStallCpuVerdict(entry);
1238
+ if (verdict.cpuVerdict === "busy") {
1239
+ if (this.state.clients.get(key) === entry.client) {
1240
+ this.scheduleTypeScriptIdleEviction(key);
1241
+ }
1242
+ this.logNotifyStallCpuBusy(key, entry, filePath, 0, verdict);
1243
+ return;
1244
+ }
1245
+ if (this.state.clients.get(key) !== entry.client)
1246
+ return;
1247
+ this.demoteForNotifyStall(key, entry, filePath, {
1248
+ ...reason,
1249
+ discriminator: "consecutive-timeouts",
1250
+ cpuVerdict: verdict.cpuVerdict,
1251
+ });
1252
+ }
1253
+ /**
1254
+ * #2358: classify a stalled server's live process CPU.
1255
+ *
1256
+ * A client with no process handle (a test/mock double, a legacy client)
1257
+ * reports "unmeasured" — the caller keeps the pre-#2358 demote-at-budget
1258
+ * behavior, because there is no liveness evidence to override it. A sampled
1259
+ * process that burns a core is BUSY; anything else is FLAT, including a
1260
+ * query that failed to resolve the pid — a server we cannot measure is not
1261
+ * proven busy, and the replacement self-heal is cheap.
1262
+ */
1263
+ async notifyStallCpuVerdict(entry) {
1264
+ const pid = entry.client.getProcessPid?.();
1265
+ if (pid === undefined || !(Number.isFinite(pid) && pid > 0)) {
1266
+ return { cpuVerdict: "unmeasured", cpuPercent: null };
1267
+ }
1268
+ const sample = await sampleProcessTreeCpuPercent(pid, notifyStallCpuSampleMs(), NOTIFY_STALL_CPU_BUSY_FLOOR_PERCENT);
1269
+ if (!sample.measured) {
1270
+ return { cpuVerdict: "unmeasured", cpuPercent: null };
1271
+ }
1272
+ if (sample.busy) {
1273
+ return { cpuVerdict: "busy", cpuPercent: sample.cpuPercent };
1274
+ }
1275
+ return { cpuVerdict: "flat", cpuPercent: sample.cpuPercent };
1276
+ }
1277
+ /**
1278
+ * #2358: one bounded row per busy defer. Not a failure record — the
1279
+ * discrimination succeeded — so it is a plain latency phase; its volume is
1280
+ * bounded by the re-arm cadence of one outstanding write (at most the wedge
1281
+ * cap divided by the budget, ~6 fires at defaults).
1282
+ */
1283
+ logNotifyStallCpuBusy(key, entry, filePath, outstandingMs, verdict, extra = {}) {
1284
+ emitBounded("lsp_notify_stall_cpu_busy", `${key}:${normalizeMapKey(filePath)}`, {
1285
+ type: "phase",
1286
+ durationMs: outstandingMs,
1287
+ metadata: {
1288
+ serverId: entry.info.id,
1289
+ clientKey: key,
1290
+ outstandingMs,
1291
+ cpuPercent: verdict.cpuPercent ?? null,
1292
+ ...extra,
1293
+ },
1294
+ }, {
1295
+ ledgerKind: "lsp-notify-stall-cpu-busy",
1296
+ risingEdgePer: "identity",
1297
+ });
1298
+ }
1299
+ /**
1300
+ * #2358: the patience window a wedged auxiliary write earns.
1301
+ *
1302
+ * max(fixed floor, k x EWMA per-write latency x unacked depth), capped so a
1303
+ * busy-but-never-draining server still respawns within the cap. The EWMA
1304
+ * comes from DRAINED notify barriers (see {@link noteAuxNotifyDrainLatency});
1305
+ * with no history the fixed floor (the pre-#2358 window) stands.
1306
+ */
1307
+ auxNotifyWedgeBudgetMs(key) {
1308
+ let budgetMs = notifyWedgedMs();
1309
+ const ewma = this.auxNotifyDrainLatencyEwma.get(key);
1310
+ if (ewma !== undefined && ewma > 0) {
1311
+ const unacked = this.auxNotifyInflight.get(key)?.unacked ?? 0;
1312
+ budgetMs = Math.max(budgetMs, NOTIFY_WEDGED_EWMA_MULTIPLIER * ewma * unacked);
1313
+ }
1314
+ return Math.min(budgetMs, notifyWedgedCapMs());
1315
+ }
1108
1316
  /**
1109
1317
  * The demotion itself, shared by the #743 consecutive-timeout streak and the
1110
1318
  * #1459 wedged-write rule. Both mean the same thing — this client's input path
1111
1319
  * is not moving — and both need the same teardown.
1112
1320
  */
1113
1321
  demoteForNotifyStall(key, entry, filePath, reason) {
1322
+ // An async verdict belongs to one client generation. Never let a
1323
+ // predecessor's decision delete or cool down its replacement.
1324
+ if (this.state.clients.get(key) !== entry.client)
1325
+ return;
1114
1326
  this.notifyWriteBackpressureStreak.delete(key);
1115
- this.outstandingAuxNotifyWrites.delete(key);
1327
+ this.releaseOutstandingAuxNotifyWrite(key);
1116
1328
  // #1714: the demoted client is torn down, so its backlog count describes a
1117
1329
  // process that no longer exists. Leaving it would make the replacement start
1118
1330
  // at the ceiling and pay a barrier on its first file.
1119
1331
  this.auxNotifyInflight.delete(key);
1120
1332
  this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
1333
+ this.notifyStallDemotions.set(key, Date.now());
1121
1334
  void entry.client.shutdown().catch(() => { });
1122
1335
  this.state.clients.delete(key);
1123
1336
  this.state.clientSpawnedAt.delete(key);
@@ -1180,6 +1393,87 @@ export class LSPService {
1180
1393
  }
1181
1394
  this.auxNotifyInflight.set(key, { client, unacked: 1 });
1182
1395
  }
1396
+ /**
1397
+ * #2358: fold one DRAINED notify barrier's per-write latency into the
1398
+ * per-key EWMA. A drained round-trip proves the server processed its
1399
+ * `outstanding` backlog after the ping; duration / outstanding is the
1400
+ * per-document service time the single-threaded scanner actually achieves
1401
+ * under load, which is exactly what a busy-server patience window must be
1402
+ * priced from (#2358's evidence: an 855 ms per-answer server with an
1403
+ * 8-write backlog, able to drain in 10-20 s while healthy).
1404
+ */
1405
+ noteAuxNotifyDrainLatency(key, durationMs, outstanding, record) {
1406
+ if (!(outstanding > 0 && durationMs > 0))
1407
+ return;
1408
+ if (record && this.auxNotifyInflight.get(key) !== record)
1409
+ return;
1410
+ const perWriteMs = durationMs / outstanding;
1411
+ const prev = this.auxNotifyDrainLatencyEwma.get(key);
1412
+ this.auxNotifyDrainLatencyEwma.set(key, prev === undefined ? perWriteMs : 0.5 * perWriteMs + 0.5 * prev);
1413
+ }
1414
+ /**
1415
+ * #2358: decide whether a wedged notify write's teardown really fires.
1416
+ *
1417
+ * Before #2358 the breaker killed a server whose write stayed outstanding
1418
+ * past a FIXED window, conflating a dead input path with a busy scanner
1419
+ * draining a burst — the live opengrep kill at #2358's head. The decision
1420
+ * now has two guards, and the record names which one fired:
1421
+ *
1422
+ * - the window is ADAPTIVE — max(fixed floor, k x EWMA per-write latency x
1423
+ * unacked depth), capped at {@link notifyWedgedCapMs} — so a server that
1424
+ * historically answers in 855 ms with 8 writes queued earns 13.7 s (k=2),
1425
+ * not 10 s;
1426
+ * - past the window, the server's process CPU is sampled twice; a BUSY
1427
+ * server is left alone (the write stays outstanding and the caller re-arms
1428
+ * this timer), a FLAT or unmeasured one is torn down.
1429
+ *
1430
+ * The hard cap still kills: a busy server that cannot drain inside it is
1431
+ * damaged, and the replacement is the self-heal the breaker exists to
1432
+ * provide.
1433
+ */
1434
+ async decideNotifyStallTeardown(key, entry, filePath, token) {
1435
+ if (this.state.clients.get(key) !== entry.client ||
1436
+ this.outstandingAuxNotifyWrites.get(key) !== token) {
1437
+ throw new Error("stale notify generation");
1438
+ }
1439
+ const outstandingMs = Date.now() - token.startedAt;
1440
+ const armedBudgetMs = token.armedBudgetMs;
1441
+ if (outstandingMs >= notifyWedgedCapMs()) {
1442
+ return {
1443
+ action: "demote",
1444
+ reason: {
1445
+ outstandingMs,
1446
+ discriminator: "cap-exceeded",
1447
+ budgetMs: armedBudgetMs,
1448
+ },
1449
+ };
1450
+ }
1451
+ const verdict = await this.notifyStallCpuVerdict(entry);
1452
+ if (this.state.clients.get(key) !== entry.client ||
1453
+ this.outstandingAuxNotifyWrites.get(key) !== token) {
1454
+ throw new Error("stale notify generation");
1455
+ }
1456
+ if (verdict.cpuVerdict === "busy") {
1457
+ this.logNotifyStallCpuBusy(key, entry, filePath, outstandingMs, verdict, {
1458
+ budgetMs: armedBudgetMs,
1459
+ });
1460
+ return { action: "rearm", budgetMs: this.auxNotifyWedgeBudgetMs(key) };
1461
+ }
1462
+ return {
1463
+ action: "demote",
1464
+ reason: {
1465
+ outstandingMs,
1466
+ discriminator: verdict.cpuVerdict === "unmeasured"
1467
+ ? "budget-exceeded"
1468
+ : "budget-exceeded-cpu-flat",
1469
+ budgetMs: armedBudgetMs,
1470
+ ewmaInputMs: this.auxNotifyDrainLatencyEwma.get(key),
1471
+ unackedDepth: this.auxNotifyInflight.get(key)?.unacked ?? 0,
1472
+ cpuVerdict: verdict.cpuVerdict,
1473
+ cpuPercent: verdict.cpuPercent ?? null,
1474
+ },
1475
+ };
1476
+ }
1183
1477
  /**
1184
1478
  * #1714: is this auxiliary already holding as many documents as it may?
1185
1479
  *
@@ -1310,11 +1604,17 @@ export class LSPService {
1310
1604
  // and the waiter is what latches. A second latch here would be
1311
1605
  // unreachable, and no test could hold it honest.
1312
1606
  }
1607
+ // #2358: a DRAINED round-trip is a per-write latency measurement —
1608
+ // the server really processed its backlog — so it feeds the EWMA
1609
+ // the adaptive wedge window is priced from.
1610
+ const durationMs = Date.now() - startedAt;
1611
+ if (drained)
1612
+ this.noteAuxNotifyDrainLatency(key, durationMs, outstanding, record);
1313
1613
  this.noteDrainBarrierOutcome(key, entry, filePath, context, {
1314
1614
  unacked: outstanding,
1315
1615
  limit,
1316
1616
  waitMs,
1317
- durationMs: Date.now() - startedAt,
1617
+ durationMs,
1318
1618
  outcome: drained ? "drained" : "stalled",
1319
1619
  });
1320
1620
  return drained;
@@ -1437,7 +1737,7 @@ export class LSPService {
1437
1737
  // respawned) says nothing about this client's stdin — drop it, so a stale
1438
1738
  // entry can never starve a healthy server.
1439
1739
  if (outstanding && outstanding.client !== entry.client) {
1440
- this.outstandingAuxNotifyWrites.delete(clientKey);
1740
+ this.releaseOutstandingAuxNotifyWrite(clientKey, outstanding);
1441
1741
  }
1442
1742
  else if (outstanding) {
1443
1743
  const outstandingMs = Date.now() - outstanding.startedAt;
@@ -1474,30 +1774,70 @@ export class LSPService {
1474
1774
  startedAt: Date.now(),
1475
1775
  client: entry.client,
1476
1776
  settled,
1477
- // A write nothing accepts for the whole wedge window is a dead input
1478
- // path, not a slow scan. Armed HERE rather than checked by the next
1479
- // waiter: inside a burst every waiter arrives within one budget, so a
1480
- // waiter-side check could never see the wedge window elapse and a
1481
- // wedged scanner was never demoted. Unref'd so it cannot hold a
1482
- // one-shot host alive, and cleared on release.
1483
- wedgeTimer: setTimeout(() => {
1484
- if (this.outstandingAuxNotifyWrites.get(clientKey) !== token)
1485
- return;
1486
- this.demoteForNotifyStall(clientKey, entry, filePath, {
1487
- outstandingMs: Date.now() - token.startedAt,
1488
- });
1777
+ armedBudgetMs: 0,
1778
+ // SAFETY: the handle is a torn-initialized placeholder. It is armed
1779
+ // a beat later in this same atomic claim (no await in between) and
1780
+ // thereafter only ever written by this token's own fire/release
1781
+ // closures, so the placeholder value is never read by anyone.
1782
+ wedgeTimer: undefined,
1783
+ resolveSettled: () => resolveSettled?.(),
1784
+ };
1785
+ // A write nothing accepts for the whole wedge window is a dead input
1786
+ // path, not a slow scan. Armed HERE rather than checked by the next
1787
+ // waiter: inside a burst every waiter arrives within one budget, so a
1788
+ // waiter-side check could never see the wedge window elapse and a
1789
+ // wedged scanner was never demoted. Unref'd so it cannot hold a
1790
+ // one-shot host alive, and cleared on release.
1791
+ //
1792
+ // #2358: the window itself is now ADAPTIVE (issue's `max(fixed,
1793
+ // k x EWMA per-answer latency x unacked depth)`, capped), and when it
1794
+ // fires the server's process CPU is sampled before the kill — a busy
1795
+ // server is left alone and the timer re-arms, so a scanner draining a
1796
+ // burst is never killed by construction. Every fire re-checks the
1797
+ // token identity after its awaits, so a release or a concurrent
1798
+ // demotion that lands mid-sample aborts the decision.
1799
+ const fireWedge = async () => {
1800
+ if (this.outstandingAuxNotifyWrites.get(clientKey) !== token ||
1801
+ this.state.clients.get(clientKey) !== entry.client) {
1802
+ this.releaseOutstandingAuxNotifyWrite(clientKey, token);
1803
+ return;
1804
+ }
1805
+ let decision;
1806
+ try {
1807
+ decision = await this.decideNotifyStallTeardown(clientKey, entry, filePath, token);
1808
+ }
1809
+ catch {
1810
+ // A failed decision must not strand the outstanding write or the
1811
+ // waiter: fall back to the pre-#2358 demote-at-budget teardown.
1812
+ decision = {
1813
+ action: "demote",
1814
+ reason: {
1815
+ outstandingMs: Date.now() - token.startedAt,
1816
+ discriminator: "budget-exceeded",
1817
+ budgetMs: token.armedBudgetMs,
1818
+ },
1819
+ };
1820
+ }
1821
+ if (this.outstandingAuxNotifyWrites.get(clientKey) !== token ||
1822
+ this.state.clients.get(clientKey) !== entry.client) {
1823
+ this.releaseOutstandingAuxNotifyWrite(clientKey, token);
1824
+ return;
1825
+ }
1826
+ if (decision.action === "demote") {
1827
+ this.demoteForNotifyStall(clientKey, entry, filePath, decision.reason);
1489
1828
  resolveSettled?.();
1490
- }, notifyWedgedMs()),
1829
+ return;
1830
+ }
1831
+ token.wedgeTimer = setTimeout(() => void fireWedge(), decision.budgetMs);
1832
+ token.wedgeTimer.unref?.();
1491
1833
  };
1834
+ token.armedBudgetMs = this.auxNotifyWedgeBudgetMs(clientKey);
1835
+ token.wedgeTimer = setTimeout(() => void fireWedge(), token.armedBudgetMs);
1492
1836
  token.wedgeTimer.unref?.();
1493
1837
  this.outstandingAuxNotifyWrites.set(clientKey, token);
1494
1838
  return {
1495
1839
  release: () => {
1496
- clearTimeout(token.wedgeTimer);
1497
- if (this.outstandingAuxNotifyWrites.get(clientKey) === token) {
1498
- this.outstandingAuxNotifyWrites.delete(clientKey);
1499
- }
1500
- resolveSettled?.();
1840
+ this.releaseOutstandingAuxNotifyWrite(clientKey, token);
1501
1841
  },
1502
1842
  };
1503
1843
  }
@@ -1578,6 +1918,30 @@ export class LSPService {
1578
1918
  return entries.sort((a, b) => (priority.get(a.serverId) ?? Number.MAX_SAFE_INTEGER) -
1579
1919
  (priority.get(b.serverId) ?? Number.MAX_SAFE_INTEGER));
1580
1920
  }
1921
+ /**
1922
+ * Documents currently open on any live client rooted at or under `cwd`
1923
+ * (#2430), as normalized path keys.
1924
+ *
1925
+ * The third contributor to the observational mutation net's tracked-file
1926
+ * set, alongside the read-guard's read/write set and the widget's diagnostic
1927
+ * files. It is pure in-memory enumeration — no spawn, no open, no stat —
1928
+ * and it is deliberately capped by the caller rather than here, because the
1929
+ * cap belongs to the sweep's budget, not to the pool's bookkeeping.
1930
+ */
1931
+ getOpenDocumentPaths(cwd) {
1932
+ const paths = new Set();
1933
+ for (const { client } of this.activeClientsForCwd(cwd)) {
1934
+ try {
1935
+ for (const filePath of client.openDocumentPaths?.() ?? [])
1936
+ paths.add(filePath);
1937
+ }
1938
+ catch {
1939
+ // A client torn down mid-enumeration contributes nothing; the sweep
1940
+ // is advisory and must never break on a dying server.
1941
+ }
1942
+ }
1943
+ return [...paths];
1944
+ }
1581
1945
  /**
1582
1946
  * Get or create LSP client for a file
1583
1947
  * Prevents duplicate client creation via in-flight promise tracking
@@ -1588,14 +1952,76 @@ export class LSPService {
1588
1952
  // Primary selection considers language servers only — auxiliary servers
1589
1953
  // (opengrep, …) attach alongside the primary and are never chosen as it.
1590
1954
  const servers = getServersForFileWithConfig(filePath).filter((s) => s.role !== "auxiliary");
1591
- const serverWaitOverrideMs = servers.reduce((max, server) => Math.max(max, server.clientWaitTimeoutMs ?? 0), 0);
1955
+ // #2525: candidate roots are resolved LAZILY, and at most once per call.
1956
+ // They are deliberately NOT hoisted: `NearestRoot` (clients/lsp/server.ts)
1957
+ // caches successful hits but never negatives, so a fallback that cannot
1958
+ // root here — DenoServer (`fallbackFor: "typescript"`, gated on
1959
+ // deno.json(c)) in a project with no deno.json — re-walks to the
1960
+ // filesystem root on EVERY call, by design, so that a deno.json
1961
+ // scaffolded mid-session is picked up without a restart. Hoisting would
1962
+ // charge that walk to every touch of every .ts file in every non-Deno
1963
+ // project. The sequential trial loop below needs no roots of its own
1964
+ // (`ensureClientForServer` resolves and bails at `!root` before any spawn
1965
+ // work), so a served touch — the hot path — resolves nothing extra at
1966
+ // all. Only the branches that EMIT a candidate list ask, and all of them
1967
+ // are cold paths.
1968
+ const rootMemo = new Map();
1969
+ /**
1970
+ * Split the candidates into the ones that could actually have been waited
1971
+ * on and the ones that never had a spawn slot. A record's subject must be
1972
+ * its producer (#1550) — the #2524 shape.
1973
+ *
1974
+ * #2525 round 3 (F3): no SHIPPED `server.root` rejects today — every one
1975
+ * bottoms out in `markerExists`, which only ever resolves `false` on a
1976
+ * miss — so this was reasoned "removed by construction". A reviewer probe
1977
+ * disproved that: a `root()` that rejects makes this `Promise.all` reject,
1978
+ * which throws out of `getClientForFile` itself (the caller never gets
1979
+ * `undefined` back, and `lsp_client_wait_timeout` is never emitted). A
1980
+ * per-server catch degrades that candidate to unrooted instead of taking
1981
+ * the whole partition down — the same "producer" the memo already knows
1982
+ * (a failed root resolution never got a client either), now WITH the
1983
+ * failure reason so the record says why, not just that.
1984
+ */
1985
+ const partitionCandidates = async () => {
1986
+ const resolved = await Promise.all(servers.map(async (server) => {
1987
+ try {
1988
+ return {
1989
+ server,
1990
+ root: await this.resolveServerRootOnce(server, filePath, rootMemo),
1991
+ reason: undefined,
1992
+ };
1993
+ }
1994
+ catch (error) {
1995
+ return {
1996
+ server,
1997
+ root: undefined,
1998
+ reason: error instanceof Error ? error.message : String(error),
1999
+ };
2000
+ }
2001
+ }));
2002
+ const unrootedReasons = {};
2003
+ for (const entry of resolved) {
2004
+ if (entry.root === undefined && entry.reason !== undefined) {
2005
+ unrootedReasons[entry.server.id] = entry.reason;
2006
+ }
2007
+ }
2008
+ return {
2009
+ rooted: resolved
2010
+ .filter((entry) => entry.root !== undefined)
2011
+ .map(({ server, root }) => ({ server, root })),
2012
+ unrootedIds: resolved
2013
+ .filter((entry) => entry.root === undefined)
2014
+ .map((entry) => entry.server.id),
2015
+ unrootedReasons,
2016
+ };
2017
+ };
1592
2018
  // hardCapMs is a caller-imposed ceiling (e.g. pipeline budget) that
1593
2019
  // prevents tool_result from blocking the TUI for the full LSP cold-start
1594
2020
  // window. When no server config sets a wait (serverWaitOverrideMs = 0),
1595
2021
  // hardCapMs is used directly — Math.min(0, cap) = 0 would otherwise
1596
2022
  // take the no-timeout branch and block indefinitely (e.g. pyright, which
1597
2023
  // has no clientWaitTimeoutMs but can take 30s to initialize on cold start).
1598
- const serverBaseMs = Math.max(maxWaitMs ?? 0, serverWaitOverrideMs);
2024
+ const serverBaseMs = primaryServerWaitFloorMs(filePath, maxWaitMs);
1599
2025
  const effectiveMaxWaitMs = hardCapMs !== undefined
1600
2026
  ? serverBaseMs > 0
1601
2027
  ? Math.min(serverBaseMs, hardCapMs)
@@ -1621,8 +2047,15 @@ export class LSPService {
1621
2047
  // #1934: the first server whose acquisition ERRORED, as opposed to
1622
2048
  // cleanly declining. Kept so a selection that served nobody still
1623
2049
  // says which server the pool actually tried and failed to spawn.
2050
+ // #2064 carries the errored outcome VALUE with it, so the record
2051
+ // below reports whether this caller started the failed spawn or
2052
+ // joined it, instead of pinning a starter label on every joiner.
1624
2053
  let erroredServerId;
1625
- // Try each matching server
2054
+ let erroredOutcome;
2055
+ // Try each matching server in registration order.
2056
+ // `ensureClientForServer` resolves the root itself and returns before
2057
+ // any spawn work when there is none, so an unrooted fallback costs
2058
+ // this loop one already-memoized resolution and no spawn slot.
1626
2059
  for (const server of servers) {
1627
2060
  // A box, not a `let`: control-flow analysis cannot see the callback
1628
2061
  // write and would narrow a plain local to its initializer.
@@ -1631,7 +2064,7 @@ export class LSPService {
1631
2064
  };
1632
2065
  const spawned = await this.ensureClientForServer(filePath, server, resolvedRoots, noteSpawnInFlight, (reported) => {
1633
2066
  acquisition.outcome = reported;
1634
- });
2067
+ }, rootMemo);
1635
2068
  if (spawned) {
1636
2069
  logLatency({
1637
2070
  type: "phase",
@@ -1650,8 +2083,12 @@ export class LSPService {
1650
2083
  });
1651
2084
  return spawned;
1652
2085
  }
1653
- if (acquisition.outcome === "spawn-failure") {
1654
- erroredServerId ??= server.id;
2086
+ if (acquisition.outcome === "spawn-failure" ||
2087
+ acquisition.outcome === "spawn-failure-joined") {
2088
+ if (erroredServerId === undefined) {
2089
+ erroredServerId = server.id;
2090
+ erroredOutcome = acquisition.outcome;
2091
+ }
1655
2092
  }
1656
2093
  }
1657
2094
  if (erroredServerId !== undefined) {
@@ -1668,12 +2105,21 @@ export class LSPService {
1668
2105
  metadata: {
1669
2106
  serverId: erroredServerId,
1670
2107
  candidateCount: servers.length,
1671
- outcome: "spawn-failure",
2108
+ outcome: (erroredOutcome ??
2109
+ "spawn-failure"),
1672
2110
  },
1673
2111
  });
1674
2112
  }
2113
+ // #2525: reuse the roots the trial loop above already resolved rather
2114
+ // than re-walking with the RAW `server.root`. Two reasons: the raw
2115
+ // call skips the ceiling/session-root gates, so this dedupe key could
2116
+ // disagree with the identity the acquisition path actually used; and
2117
+ // on the unserved path — the only path that reaches here — every
2118
+ // unrooted candidate would pay a second full walk to the filesystem
2119
+ // root, on every touch, before the `unavailableLogged` dedupe even
2120
+ // looked at it.
1675
2121
  const unavailable = (await Promise.all(servers.map(async (server) => {
1676
- const root = await server.root(filePath);
2122
+ const root = await this.resolveServerRootOnce(server, filePath, rootMemo);
1677
2123
  return {
1678
2124
  server,
1679
2125
  key: `${server.id}:${root ? normalizeMapKey(root) : "<unresolved>"}`,
@@ -1729,11 +2175,13 @@ export class LSPService {
1729
2175
  // `inFlight` is cleared in ensureClientForServer's finally block, so a
1730
2176
  // settled acquisition can still be present here. Re-read the published
1731
2177
  // clients at the decision point; a usable client outranks the sentinel.
1732
- for (const server of servers) {
1733
- const root = await this.resolveServerRoot(server, filePath);
1734
- const client = root
1735
- ? this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`)
1736
- : undefined;
2178
+ // #2525: this is a cold path, so it is where the roots get resolved —
2179
+ // once, memoized with the trial loop's own resolutions. An unrooted
2180
+ // fallback can never have a client keyed under its (non-existent)
2181
+ // root, so it is neither probed here nor named in the record below.
2182
+ const { rooted, unrootedIds, unrootedReasons } = await partitionCandidates();
2183
+ for (const { server, root } of rooted) {
2184
+ const client = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
1737
2185
  if (client?.isAlive())
1738
2186
  return { client, info: server };
1739
2187
  }
@@ -1745,16 +2193,46 @@ export class LSPService {
1745
2193
  durationMs: 0,
1746
2194
  metadata: {
1747
2195
  maxWaitMs: effectiveMaxWaitMs,
1748
- serverIds: servers.map((server) => server.id),
2196
+ serverIds: rooted.map(({ server }) => server.id),
2197
+ unrootedCandidates: {
2198
+ count: unrootedIds.length,
2199
+ ids: unrootedIds,
2200
+ ...(Object.keys(unrootedReasons).length > 0
2201
+ ? { reasons: unrootedReasons }
2202
+ : {}),
2203
+ },
1749
2204
  reason: "budget_skipped_known_slow",
1750
2205
  },
1751
2206
  });
1752
2207
  return undefined;
1753
2208
  }
1754
2209
  if (waitResult === timeoutSentinel) {
2210
+ // #2525: likewise a cold path — resolve here, not up front.
2211
+ //
2212
+ // #2525 round 3 (L2): `partitionCandidates` does UNCACHED negative
2213
+ // root walks (`NearestRoot` never caches misses, by design — see the
2214
+ // comment above `rootMemo`) and this branch only reaches it AFTER
2215
+ // `effectiveMaxWaitMs` has already elapsed. Measured by the reviewer:
2216
+ // 2.06ms native, ~150ms on the #462 slow-FS profile — unbounded on top
2217
+ // of a budget whose entire contract is "return within the cap". Bound
2218
+ // it with a small ceiling of its own (a quarter of the caller's hard
2219
+ // cap when one was given, capped at 250ms) rather than leaving it the
2220
+ // one piece of async work in this function with no bound at all
2221
+ // (the "async work needs both bounds" shape — here there is no
2222
+ // per-call abort signal to race, only the timer bound applies).
2223
+ const partitionBudgetMs = hardCapMs !== undefined ? Math.min(250, hardCapMs / 4) : 250;
2224
+ const partitioned = await withDeadline(partitionCandidates(), {
2225
+ ms: partitionBudgetMs,
2226
+ onTimeout: "undefined",
2227
+ });
2228
+ const { rooted, unrootedIds, unrootedReasons } = partitioned ?? {
2229
+ rooted: [],
2230
+ unrootedIds: [],
2231
+ unrootedReasons: {},
2232
+ };
1755
2233
  // Snapshot known client health — scan by serverId prefix (no root needed)
1756
2234
  const knownHealth = [...this.state.clients.entries()]
1757
- .filter(([k]) => servers.some((s) => k.startsWith(`${s.id}:`)))
2235
+ .filter(([k]) => rooted.some(({ server }) => k.startsWith(`${server.id}:`)))
1758
2236
  .map(([k, c]) => ({
1759
2237
  serverId: k.split(":")[0],
1760
2238
  alive: c.isAlive(),
@@ -1767,9 +2245,23 @@ export class LSPService {
1767
2245
  durationMs: effectiveMaxWaitMs,
1768
2246
  metadata: {
1769
2247
  maxWaitMs: effectiveMaxWaitMs,
1770
- serverIds: servers.map((s) => s.id),
2248
+ // `serverIds` names the ROOTED pool the timeout record reports
2249
+ // against, not "servers this call actually waited on" — the
2250
+ // sequential trial loop can time out having reached only the
2251
+ // first of several rooted candidates (case 2 in the regression
2252
+ // test pins both `["typescript","deno"]` even though the loop
2253
+ // never got past `typescript`).
2254
+ serverIds: rooted.map(({ server }) => server.id),
2255
+ unrootedCandidates: {
2256
+ count: unrootedIds.length,
2257
+ ids: unrootedIds,
2258
+ ...(Object.keys(unrootedReasons).length > 0
2259
+ ? { reasons: unrootedReasons }
2260
+ : {}),
2261
+ },
1771
2262
  // servers absent from knownHealth were never spawned or are still spawning
1772
2263
  knownClientHealth: knownHealth,
2264
+ ...(partitioned === undefined ? { partition: "timed-out" } : {}),
1773
2265
  },
1774
2266
  });
1775
2267
  return undefined;
@@ -1777,8 +2269,10 @@ export class LSPService {
1777
2269
  return waitResult;
1778
2270
  }
1779
2271
  /**
1780
- * Get or create ALL LSP clients that can serve a file.
1781
- * Used for diagnostics aggregation across complementary servers.
2272
+ * Get or create all complementary LSP clients that can serve a file. Alternate
2273
+ * language servers marked with `fallbackFor` remain sequential fallbacks: an
2274
+ * aggregate diagnostics pass must not launch them beside a working preferred
2275
+ * server merely because it requested `clientScope: "all"`.
1782
2276
  */
1783
2277
  async getClientsForFile(filePath, excludeServerIds, resolvedRoots) {
1784
2278
  const allServers = getServersForFileWithConfig(filePath);
@@ -1787,28 +2281,59 @@ export class LSPService {
1787
2281
  : allServers;
1788
2282
  if (servers.length === 0)
1789
2283
  return { clients: [], serverCountAttempted: 0 };
1790
- // Count servers with a valid root as "attempted" — extension-only matches
1791
- // that fail the root check are not real spawn attempts.
1792
- const roots = await Promise.all(servers.map((s) => this.resolveServerRoot(s, filePath)));
1793
- const serverCountAttempted = roots.filter(Boolean).length;
1794
- const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server, resolvedRoots)));
1795
- return {
1796
- clients: spawned.filter((entry) => Boolean(entry)),
1797
- serverCountAttempted,
2284
+ // Resolve once to keep the attempted count tied to servers with a real root.
2285
+ const rootedServers = (await Promise.all(servers.map(async (server) => ({
2286
+ server,
2287
+ root: await this.resolveServerRoot(server, filePath),
2288
+ })))).filter((entry) => entry.root !== undefined);
2289
+ let serverCountAttempted = 0;
2290
+ const acquisitions = new Map();
2291
+ const acquire = (server) => {
2292
+ serverCountAttempted += 1;
2293
+ return this.ensureClientForServer(filePath, server, resolvedRoots);
1798
2294
  };
2295
+ // Start complementary servers immediately. An alternate waits only for its
2296
+ // own preferred server, not for unrelated scanners, and starts if that server
2297
+ // declines. Registry order makes chained fallbacks deterministic.
2298
+ for (const { server } of rootedServers) {
2299
+ const preferred = server.fallbackFor
2300
+ ? acquisitions.get(server.fallbackFor)
2301
+ : undefined;
2302
+ const acquisition = preferred
2303
+ ? preferred.then((entry) => entry === undefined ? acquire(server) : undefined)
2304
+ : acquire(server);
2305
+ acquisitions.set(server.id, acquisition);
2306
+ }
2307
+ const results = await Promise.all(acquisitions.values());
2308
+ const clients = results.filter((entry) => entry !== undefined);
2309
+ return { clients, serverCountAttempted };
1799
2310
  }
1800
2311
  /**
1801
2312
  * Spawn/get the AUXILIARY clients for a file (role:"auxiliary") restricted to
1802
2313
  * the enabled set. These attach alongside the primary on the with-auxiliary
1803
2314
  * diagnostics path (cross-cutting scanners like opengrep).
1804
2315
  */
1805
- async getAuxiliaryClientsForFile(filePath, enabledIds) {
2316
+ async getAuxiliaryClientsForFile(filePath, enabledIds, onOutcome, maxWaitMs, signal, hook) {
1806
2317
  if (this.checkDestroyed() || enabledIds.size === 0)
1807
2318
  return [];
1808
2319
  const servers = getServersForFileWithConfig(filePath).filter((s) => s.role === "auxiliary" && enabledIds.has(s.id));
1809
2320
  if (servers.length === 0)
1810
2321
  return [];
1811
- const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
2322
+ const rootMemo = new Map();
2323
+ const effectiveSignal = signal ?? getAmbientAbortSignal();
2324
+ const effectiveHook = hook ?? "tool_result_edit";
2325
+ const spawned = await Promise.all(servers.map(async (server) => {
2326
+ const acquisition = this.ensureClientForServer(filePath, server, undefined, undefined, (reported) => onOutcome?.(server.id, reported), rootMemo);
2327
+ if (!maxWaitMs || maxWaitMs <= 0) {
2328
+ return acquisition;
2329
+ }
2330
+ return bounded(acquisition, {
2331
+ ms: maxWaitMs,
2332
+ signal: effectiveSignal,
2333
+ hook: effectiveHook,
2334
+ label: `auxiliary-spawn:${server.id}`,
2335
+ });
2336
+ }));
1812
2337
  return spawned.filter((entry) => Boolean(entry));
1813
2338
  }
1814
2339
  /**
@@ -1857,11 +2382,11 @@ export class LSPService {
1857
2382
  return undefined;
1858
2383
  }
1859
2384
  /**
1860
- * Read-only liveness check for one server/file pair. Unlike
1861
- * `getClientForFile`, this never creates or warms a client; it only resolves
1862
- * the server's root and checks the already-connected client map.
2385
+ * Read-only Gate-B readiness check for one auxiliary server/root. A connected
2386
+ * process is not yet a diagnostic producer: only its first publication proves
2387
+ * that this root's client can supersede an in-process fallback runner.
1863
2388
  */
1864
- async isServerAliveForFile(serverId, filePath) {
2389
+ async hasServerPublishedForFileRoot(serverId, filePath) {
1865
2390
  if (this.checkDestroyed())
1866
2391
  return false;
1867
2392
  for (const server of getServersForFileWithConfig(filePath)) {
@@ -1871,20 +2396,30 @@ export class LSPService {
1871
2396
  if (!root)
1872
2397
  continue;
1873
2398
  const key = `${server.id}:${normalizeMapKey(root)}`;
1874
- if (this.state.clients.get(key)?.isAlive())
2399
+ const client = this.state.clients.get(key);
2400
+ // #2324 F1: per-file grain. `diagnosticsVersion` is client-global —
2401
+ // any sibling path's publication bumps it, so it cannot answer "did
2402
+ // THIS server publish for THIS file?" (client.ts:339-341). A file
2403
+ // this server never touched would otherwise read as gated-open the
2404
+ // moment any other file on the same client got a publication.
2405
+ if (client?.isAlive() &&
2406
+ client.getDiagnosticsVersionForPath(filePath) > 0) {
1875
2407
  return true;
2408
+ }
1876
2409
  }
1877
2410
  return false;
1878
2411
  }
1879
2412
  /**
1880
2413
  * #2001/#2002 collect-later: read-only cached-diagnostics probe for the
1881
2414
  * turn-end late-auxiliary delivery (`clients/runtime-turn.ts`). Like
1882
- * `isServerAliveForFile`, this NEVER creates or warms a client — it only
2415
+ * the Gate-B readiness check, this NEVER creates or warms a client — it only
1883
2416
  * resolves each requested server's root and reads the already-connected
1884
2417
  * client's cached diagnostics for `filePath`. Servers with no live client
1885
- * are simply absent from the returned map (the caller drops those pairs
1886
- * silently); a present server maps to its cache contents, possibly empty
1887
- * (still scanning).
2418
+ * are absent unless notify-stall teardown marked that generation as
2419
+ * replaceable; that status carries the demotion timestamp so turn-end late
2420
+ * coverage can correlate each pair to the removed generation. A live
2421
+ * client is present even when its per-file cache entry is empty; its
2422
+ * timestamp distinguishes a published clean result from no publication.
1888
2423
  */
1889
2424
  async readCachedDiagnosticsForServers(filePath, serverIds) {
1890
2425
  const out = new Map();
@@ -1898,9 +2433,25 @@ export class LSPService {
1898
2433
  continue;
1899
2434
  const key = `${server.id}:${normalizeMapKey(root)}`;
1900
2435
  const client = this.state.clients.get(key);
1901
- if (!client?.isAlive())
2436
+ if (!client?.isAlive()) {
2437
+ const demotedAt = this.notifyStallDemotions.get(key);
2438
+ if (demotedAt !== undefined) {
2439
+ out.set(server.id, {
2440
+ diags: [],
2441
+ notifyStallDemoted: true,
2442
+ demotedAt,
2443
+ });
2444
+ }
1902
2445
  continue;
1903
- out.set(server.id, client.getDiagnostics(filePath));
2446
+ }
2447
+ // A replacement is live again. The old generation's marker no longer
2448
+ // describes this client and must not make a later absence look transient.
2449
+ this.notifyStallDemotions.delete(key);
2450
+ const entry = client.getAllDiagnostics().get(normalizeMapKey(filePath));
2451
+ out.set(server.id, {
2452
+ diags: entry?.diags ?? [],
2453
+ publishedAt: entry?.ts,
2454
+ });
1904
2455
  }
1905
2456
  return out;
1906
2457
  }
@@ -2106,7 +2657,29 @@ export class LSPService {
2106
2657
  noteSpawnVerdict(key, verdict) {
2107
2658
  this.lastSpawnVerdict.set(key, verdict);
2108
2659
  }
2109
- async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight, onOutcome) {
2660
+ /**
2661
+ * {@link resolveServerRoot}, memoized against a caller-owned map so one
2662
+ * acquisition asks a given server for its root at most once (#2525).
2663
+ *
2664
+ * Resolution is cheap exactly when it SUCCEEDS: `NearestRoot` caches hits,
2665
+ * but deliberately never caches negatives (so a marker scaffolded
2666
+ * mid-session is picked up without a restart). An unrooted candidate
2667
+ * therefore re-walks to the filesystem root on every call, which is why the
2668
+ * acquisition path and the records it emits must share one resolution
2669
+ * instead of each taking their own. Without a `memo` this is a plain
2670
+ * passthrough, for callers that have no acquisition to share with.
2671
+ */
2672
+ resolveServerRootOnce(server, filePath, memo) {
2673
+ if (!memo)
2674
+ return this.resolveServerRoot(server, filePath);
2675
+ const pending = memo.get(server.id);
2676
+ if (pending !== undefined)
2677
+ return pending;
2678
+ const started = this.resolveServerRoot(server, filePath);
2679
+ memo.set(server.id, started);
2680
+ return started;
2681
+ }
2682
+ async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight, onOutcome, rootMemo) {
2110
2683
  const handoff = this.generationHandoff;
2111
2684
  if (handoff) {
2112
2685
  await handoff;
@@ -2116,7 +2689,7 @@ export class LSPService {
2116
2689
  if (this.checkDestroyed())
2117
2690
  return undefined;
2118
2691
  }
2119
- const root = await this.resolveServerRoot(server, filePath);
2692
+ const root = await this.resolveServerRootOnce(server, filePath, rootMemo);
2120
2693
  if (!root || this.checkDestroyed())
2121
2694
  return undefined;
2122
2695
  if (server.role !== "auxiliary") {
@@ -2219,6 +2792,7 @@ export class LSPService {
2219
2792
  intentional: wasIntentional,
2220
2793
  },
2221
2794
  });
2795
+ this.releaseOutstandingAuxNotifyWrite(key);
2222
2796
  try {
2223
2797
  await existing.shutdown();
2224
2798
  }
@@ -2372,12 +2946,21 @@ export class LSPService {
2372
2946
  if (isOptionalServer)
2373
2947
  this.optionalDisabled.delete(key);
2374
2948
  }
2949
+ // #2064: did THIS caller start the language-server process, or did it
2950
+ // join a spawn another caller already had in flight? The answer is only
2951
+ // knowable here, before the await — downstream of `await spawnPromise`
2952
+ // the two are indistinguishable, which is exactly how the 3.0x
2953
+ // over-count happened. There are two join sites and both count as
2954
+ // joins: the unguarded read below, and the `raced` re-read inside the
2955
+ // spawn gate, which catches a caller that reached the gate before the
2956
+ // starter published its promise.
2957
+ let startedSpawn = false;
2375
2958
  let spawnPromise = this.state.inFlight.get(key);
2376
2959
  if (!spawnPromise) {
2377
2960
  const started = await this.withClientSpawnGate(async () => {
2378
2961
  const raced = this.state.inFlight.get(key);
2379
2962
  if (raced)
2380
- return { promise: raced };
2963
+ return { promise: raced, startedSpawn: false };
2381
2964
  // `server.root()` and dead-client cleanup above are async. A reset during
2382
2965
  // either gap must not let this retired generation start a late spawn.
2383
2966
  if (this.checkDestroyed())
@@ -2386,11 +2969,12 @@ export class LSPService {
2386
2969
  return undefined;
2387
2970
  const promise = this.spawnClient(server, root, key, filePath, allowInstall);
2388
2971
  this.state.inFlight.set(key, promise);
2389
- return { promise };
2972
+ return { promise, startedSpawn: true };
2390
2973
  });
2391
2974
  if (!started)
2392
2975
  return undefined;
2393
2976
  spawnPromise = started.promise;
2977
+ startedSpawn = started.startedSpawn;
2394
2978
  }
2395
2979
  // Announce the in-flight spawn so the caller can skip a doomed touch
2396
2980
  // wait. The announcement never returns a client and never
@@ -2410,18 +2994,41 @@ export class LSPService {
2410
2994
  // #1934: a client here cost a process WAIT, whether this caller
2411
2995
  // started the spawn or joined another caller's in-flight promise.
2412
2996
  // Either way the selection was not served from the warm pool.
2997
+ // #2064: which of the two it was is now named, so the record can
2998
+ // answer "how many processes started" as well as "how many
2999
+ // selections paid a wait". `startedSpawn` is captured before the
3000
+ // await, because after it the two are indistinguishable.
2413
3001
  //
2414
3002
  // The verdict read is synchronous and sits in the same microtask as
2415
3003
  // the await above, so it can only see the attempt just settled.
2416
3004
  onOutcome?.(spawned
2417
- ? "cold-spawn"
3005
+ ? startedSpawn
3006
+ ? "cold-spawn"
3007
+ : "cold-spawn-joined"
2418
3008
  : this.lastSpawnVerdict.get(key) === "failed"
2419
- ? "spawn-failure"
3009
+ ? startedSpawn
3010
+ ? "spawn-failure"
3011
+ : "spawn-failure-joined"
2420
3012
  : "declined");
2421
3013
  return spawned;
2422
3014
  }
2423
3015
  catch (err) {
2424
3016
  // A throwing spawn promise is an errored acquisition by definition.
3017
+ //
3018
+ // #2064 review F2: deliberately NOT split into a starter/joiner pair
3019
+ // like the resolve path above, because nothing could observe the
3020
+ // split. `spawnClient` never rethrows; it catches its own spawn and
3021
+ // initialize failures and resolves `undefined`. This catch is
3022
+ // therefore reachable only when `spawnClient` throws BEFORE its own
3023
+ // `try` (the trust probe, `logSessionStart`, `recordLsp`). A joiner
3024
+ // CAN read the already-rejected promise out of `inFlight` (the #2106
3025
+ // verify measured 2 invocations for 3 callers), but it does not
3026
+ // matter: the rethrow
3027
+ // below unwinds past every `lsp_client_selected` emit site, so this
3028
+ // value is written to no record: a probe drove a throwing trust
3029
+ // check through two concurrent callers and got two rejections and
3030
+ // zero selection records. An unobservable discriminator is a
3031
+ // vacuous guard, so this path keeps the single #1934 value.
2425
3032
  onOutcome?.("spawn-failure");
2426
3033
  throw err;
2427
3034
  }
@@ -2514,6 +3121,7 @@ export class LSPService {
2514
3121
  serverId: server.id,
2515
3122
  process: spawned.process,
2516
3123
  root,
3124
+ sessionCwd: this.sessionCwd,
2517
3125
  initialization: mergedInit,
2518
3126
  initializeTimeoutMs: server.initializeTimeoutMs,
2519
3127
  launchVariant: spawned.launchVariant,
@@ -2534,6 +3142,9 @@ export class LSPService {
2534
3142
  diagnosticProviderKind: "unavailable",
2535
3143
  };
2536
3144
  this.state.clients.set(key, client);
3145
+ // #2356: this generation is the replacement the late-coverage probe was
3146
+ // waiting for. Clear the retired-generation marker before any later probe.
3147
+ this.notifyStallDemotions.delete(key);
2537
3148
  this.unavailableLogged.delete(key);
2538
3149
  // #1934 review F1: a success retires the previous verdict, so the map
2539
3150
  // never outlives the attempts it describes.
@@ -2550,6 +3161,36 @@ export class LSPService {
2550
3161
  const spawnDurationMs = Date.now() - startedAt;
2551
3162
  recordLsp(server.id, root, "spawn_success", spawnDurationMs);
2552
3163
  recordSuccessfulLspSpawn(server.id, spawnDurationMs);
3164
+ // #2064: the only latency record that a language-server PROCESS
3165
+ // started. `lsp_launch_candidate_success` covers the servers that
3166
+ // launch through `resolveAndLaunch` and never fired for TypeScript,
3167
+ // which served 913 of 941 selections in the field window — so
3168
+ // nothing in `latency.log` counted the 29.3s TypeScript spawn at
3169
+ // all. This sits at `spawnClient`'s single success path, so every
3170
+ // server reports through one record instead of a per-server
3171
+ // launcher, and `count(serverId=typescript)` is answerable from
3172
+ // `latency.log` alone. Volume is bounded by process starts: one
3173
+ // record per spawn, and a spawn is single-flighted per
3174
+ // `serverId:root` by `state.inFlight`.
3175
+ //
3176
+ // The two path fields are deliberate and are not duplicates.
3177
+ // `filePath` carries the ROOT, because the root plus `serverId` is
3178
+ // the client's identity and the unit a spawn is single-flighted on.
3179
+ // `triggerFilePath` carries the file whose touch paid for the
3180
+ // spawn, which answers a different question and is the one that
3181
+ // varies across records for the same client.
3182
+ logLatency({
3183
+ type: "phase",
3184
+ phase: "lsp_server_spawned",
3185
+ filePath: root,
3186
+ durationMs: spawnDurationMs,
3187
+ metadata: {
3188
+ serverId: server.id,
3189
+ source: spawned.source ?? "unknown",
3190
+ launchVariant: spawned.launchVariant ?? "default",
3191
+ triggerFilePath: filePath,
3192
+ },
3193
+ });
2553
3194
  if (!this.workspaceProbeLogged.has(key)) {
2554
3195
  logSessionStart(`lsp workspace-diag probe ${server.id}: advertised=${wsDiag.advertised} mode=${wsDiag.mode} provider=${wsDiag.diagnosticProviderKind}`);
2555
3196
  this.workspaceProbeLogged.add(key);
@@ -2646,6 +3287,11 @@ export class LSPService {
2646
3287
  }
2647
3288
  const startedAt = Date.now();
2648
3289
  const normalizedPath = normalizeMapKey(filePath);
3290
+ const outsideRoot = await this.findOutsideProjectRoot(filePath);
3291
+ if (outsideRoot) {
3292
+ this.recordOutsideRootDecline(filePath, outsideRoot, "touchFile");
3293
+ return { diags: [], skipReason: "outside-project-root" };
3294
+ }
2649
3295
  // #1783: every path that asks a language server anything comes through
2650
3296
  // here, so this is where the disk-drift backstop gets its heartbeat.
2651
3297
  // Deliberately NOT awaited: the sweep is rate-limited to one pass per
@@ -2665,6 +3311,12 @@ export class LSPService {
2665
3311
  const useAllClients = clientScope === "all";
2666
3312
  const resolvedPrimaryRoots = new Map();
2667
3313
  const waitSkipReasons = new Set();
3314
+ const coldAuxiliaryServerIds = new Set();
3315
+ const noteColdAuxiliary = (serverId, outcome) => {
3316
+ if (outcome === "cold-spawn" || outcome === "cold-spawn-joined") {
3317
+ coldAuxiliaryServerIds.add(serverId);
3318
+ }
3319
+ };
2668
3320
  let spawned;
2669
3321
  let serverCountAttempted;
2670
3322
  if (useAllClients) {
@@ -2675,10 +3327,27 @@ export class LSPService {
2675
3327
  else if (clientScope === "with-auxiliary") {
2676
3328
  // Primary language server + the enabled cross-cutting auxiliaries
2677
3329
  // (opengrep, …). The aggregation layer merges/dedups their diagnostics.
3330
+ const auxStartedAt = Date.now();
2678
3331
  const [entry, aux] = await Promise.all([
2679
3332
  this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons),
2680
- this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? [])),
3333
+ this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? []), noteColdAuxiliary, options.maxClientWaitMs, undefined, options.hook),
2681
3334
  ]);
3335
+ const auxDurationMs = Date.now() - auxStartedAt;
3336
+ if (options.auxiliaryServerIds && options.auxiliaryServerIds.length > 0) {
3337
+ logLatency({
3338
+ type: "phase",
3339
+ phase: "auxiliary_readiness",
3340
+ filePath: normalizedPath,
3341
+ durationMs: auxDurationMs,
3342
+ metadata: {
3343
+ serverIds: aux.map((s) => s.info.id),
3344
+ attemptedCount: options.auxiliaryServerIds.length,
3345
+ readyCount: aux.length,
3346
+ coldServerIds: [...coldAuxiliaryServerIds],
3347
+ source,
3348
+ },
3349
+ });
3350
+ }
2682
3351
  spawned = entry ? [entry, ...aux] : aux;
2683
3352
  serverCountAttempted = spawned.length;
2684
3353
  }
@@ -2780,6 +3449,10 @@ export class LSPService {
2780
3449
  // quietly reverting to the global counter, which is the defect itself.
2781
3450
  const readPathVersion = (client) => client.getDiagnosticsVersionForPath?.(filePath);
2782
3451
  const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, readPathVersion(entry.client)]));
3452
+ // #2161: anchor publication evidence before this touch's notify. A
3453
+ // later per-file cache entry, including an empty one, proves that the
3454
+ // primary answered; an empty diagnostics result alone cannot.
3455
+ const markedAtMs = Date.now();
2783
3456
  // #1458: read a late auxiliary publication BEFORE the ordinary resync
2784
3457
  // clears its client cache. Carry it only when the publication's exact
2785
3458
  // sent-content fingerprint matches this touch's content. A changed edit,
@@ -2879,14 +3552,26 @@ export class LSPService {
2879
3552
  if (!notifySkipped) {
2880
3553
  const budget = notifyWriteBudgetMs();
2881
3554
  // #1459: how long a queued auxiliary may wait for its resync slot. Bounded
2882
- // by the write budget AND by whatever the caller already declared it is
2883
- // willing to spend on this touch (`maxClientWaitMs` — cascade's cold
2884
- // snapshot passes 1000ms), minus what the client wait above already spent.
3555
+ // by the write budget and by the effective primary wait floor, minus what
3556
+ // which includes the caller's `maxClientWaitMs` and any primary
3557
+ // server `clientWaitTimeoutMs` override. The elapsed client wait is
3558
+ // subtracted below.
2885
3559
  // A flat write-budget wait would tax a caller that asked for less than one
2886
3560
  // budget in total. Non-positive means "no time left to queue": the server
2887
3561
  // is reported as uncovered immediately.
3562
+ //
3563
+ // #2239: "what the caller already declared" is `primaryServerWaitFloorMs`,
3564
+ // not the raw `options.maxClientWaitMs` — the SAME floor `getClientForFile`
3565
+ // races the primary spawn against. A cold primary configured with its own
3566
+ // `clientWaitTimeoutMs` (Ruby 30s, and the #2233 Bash/JSON/Vue/Svelte/Prisma
3567
+ // overrides) is allowed to run past the caller's flat budget, and did just
3568
+ // that by the time this line runs — charging the subtraction against the
3569
+ // flat value alone always went negative and clamped to zero. The outer
3570
+ // `Math.min(budget, …)` is unchanged, so this raises what "already spent"
3571
+ // is measured against without widening the wait beyond one write budget.
2888
3572
  const queueWaitMs = options.maxClientWaitMs !== undefined
2889
- ? Math.min(budget, Math.max(0, options.maxClientWaitMs - (Date.now() - startedAt)))
3573
+ ? Math.min(budget, Math.max(0, primaryServerWaitFloorMs(filePath, options.maxClientWaitMs) -
3574
+ (Date.now() - startedAt)))
2890
3575
  : budget;
2891
3576
  await Promise.all(spawned.map(async (entry) => {
2892
3577
  // #743: this server already has this content from a recent touch
@@ -2974,10 +3659,13 @@ export class LSPService {
2974
3659
  // streak outright, so only the late case retracts. A landing
2975
3660
  // past the WEDGE window keeps its strike: at that point the
2976
3661
  // stall was long enough that #743's demotion is the honest
2977
- // verdict, not a latency artifact.
3662
+ // verdict, not a latency artifact. #2358: the wedge window
3663
+ // is the adaptive one the timer itself arms with, so a
3664
+ // write a BUSY server would have been left alone for also
3665
+ // retracts its strike instead of accruing a ladder charge.
2978
3666
  const outstandingMs = Date.now() - writeStartedAt;
2979
3667
  if (outstandingMs > budget &&
2980
- outstandingMs <= notifyWedgedMs()) {
3668
+ outstandingMs <= this.auxNotifyWedgeBudgetMs(clientKey)) {
2981
3669
  this.retractNotifyWriteBackpressure(clientKey, entry.info.id, filePath, outstandingMs, client);
2982
3670
  }
2983
3671
  }, release);
@@ -3327,7 +4015,25 @@ export class LSPService {
3327
4015
  // Fail-open: missing capability state keeps today's push fallback.
3328
4016
  }
3329
4017
  }
3330
- const perServerWaits = spawned.map((entry) => {
4018
+ const configuredAuxCeilingMs = readEnvAuxGraceMs();
4019
+ const perServerDeclaredTimeouts = spawned.map((entry) => timeoutFor(entry.client.serverId));
4020
+ const perServerWaitTimeouts = spawned.map((entry, entryIndex) => {
4021
+ const declaredServerTimeout = perServerDeclaredTimeouts[entryIndex];
4022
+ const observedSpawnMs = getSuccessfulLspSpawnDurationMs(entry.client.serverId);
4023
+ return hasTouchAuxiliaries &&
4024
+ entry.info.role === "auxiliary" &&
4025
+ coldAuxiliaryServerIds.has(entry.client.serverId) &&
4026
+ (configuredAuxCeilingMs !== undefined ||
4027
+ (observedSpawnMs !== undefined && observedSpawnMs > 0))
4028
+ ? auxWaitBudgetMs(entry.client.serverId, true, configuredAuxCeilingMs, declaredServerTimeout)
4029
+ : perServerDeclaredTimeouts[entryIndex];
4030
+ });
4031
+ const perServerRaceBudgets = spawned.map((entry, entryIndex) => {
4032
+ return hasTouchAuxiliaries && entry.info.role === "auxiliary"
4033
+ ? auxWaitBudgetMs(entry.client.serverId, coldAuxiliaryServerIds.has(entry.client.serverId), configuredAuxCeilingMs, perServerDeclaredTimeouts[entryIndex])
4034
+ : perServerDeclaredTimeouts[entryIndex];
4035
+ });
4036
+ const perServerWaits = spawned.map((entry, entryIndex) => {
3331
4037
  // #1459: a DEFERRED server never received this content, so its version
3332
4038
  // can never advance past the baseline — waiting on it burns its whole
3333
4039
  // budget and would flip the touch to `inconclusive`, discarding a
@@ -3336,7 +4042,7 @@ export class LSPService {
3336
4042
  if (deferredResyncServerIds.has(entry.info.id)) {
3337
4043
  return Promise.resolve(undefined);
3338
4044
  }
3339
- const serverTimeout = timeoutFor(entry.client.serverId);
4045
+ const serverTimeout = perServerWaitTimeouts[entryIndex];
3340
4046
  // #1531: a per-path baseline. `clientWaitForDiagnostics` compares it
3341
4047
  // against this path's own publication stamp, so a sibling file's
3342
4048
  // publication on a shared client can no longer end this wait before the
@@ -3395,14 +4101,13 @@ export class LSPService {
3395
4101
  serverId: spawned[i].info.id,
3396
4102
  client: spawned[i].client,
3397
4103
  baseline: diagnosticBaselines.get(spawned[i].client),
4104
+ budgetMs: perServerRaceBudgets[i],
3398
4105
  }
3399
4106
  : null)
3400
4107
  .filter((x) => x !== null);
3401
- const auxCeilingMs = readEnvAuxGraceMs() ?? 2000;
3402
- // After all primaries settle, give each auxiliary the smaller of
3403
- // its declared wait budget and the global auxiliary ceiling. The
3404
- // 2000ms default admits measured ~1.3s warm scanner runs without
3405
- // making every edit pay opengrep's 3500ms cold-start allowance.
4108
+ // After all primaries settle, use the same per-auxiliary budget
4109
+ // that bounded its own diagnostic wait. Warm acquisitions retain
4110
+ // the 2000ms ceiling; cold acquisitions include observed startup.
3406
4111
  // Late aux results are dropped from this wait. A later unchanged-
3407
4112
  // content read may carry a SHA-256-bound cache publication before its
3408
4113
  // resync clears the cache; changed or unknown content never replays.
@@ -3415,7 +4120,7 @@ export class LSPService {
3415
4120
  return;
3416
4121
  const auxWaitStartedAt = Date.now();
3417
4122
  const outcomes = await Promise.all(auxWaits.map(async (aux) => {
3418
- const budgetMs = Math.min(timeoutFor(aux.serverId), auxCeilingMs);
4123
+ const { budgetMs } = aux;
3419
4124
  let timer;
3420
4125
  const timeout = new Promise((resolve) => {
3421
4126
  timer = setTimeout(() => resolve(false), budgetMs);
@@ -3515,7 +4220,24 @@ export class LSPService {
3515
4220
  // revision and probing it would replay stale findings.
3516
4221
  const collectLaterServerIds = outcomes
3517
4222
  .filter((o) => !o.publishedThisContent &&
3518
- (o.outcome === "cut_off" || o.outcome === "silent"))
4223
+ (o.outcome === "cut_off" || o.outcome === "silent") &&
4224
+ // #2324 R2-A/R3-A: ast-grep's napi fallback is a
4225
+ // SECOND producer of coverage for this exact pair,
4226
+ // dispatched CONCURRENTLY with this whole touch
4227
+ // (dispatcher.ts's Promise.all groups) — not just
4228
+ // concurrently with this wait. On a COLD touch,
4229
+ // getClientsForFile's spawn+handshake (above, before
4230
+ // waitStartedAt is ever captured) can itself take
4231
+ // long enough that napi's near-instant Gate-B check
4232
+ // records coverage BEFORE waitStartedAt — the
4233
+ // issue's own 68ms race shape. Baseline on startedAt
4234
+ // instead: stamped at touchFile's own entry, before
4235
+ // ANY spawn work, so it is the earliest instant this
4236
+ // touch's own napi run could possibly predate. Still
4237
+ // excludes a stale record from an EARLIER touch,
4238
+ // which is all the staleness guard needs.
4239
+ !(o.serverId === "ast-grep" &&
4240
+ napiFallbackCoveredSince(filePath, startedAt)))
3519
4241
  .map((o) => o.serverId);
3520
4242
  if (collectLaterServerIds.length > 0) {
3521
4243
  markPendingAuxiliaryCoverage(filePath, collectLaterServerIds);
@@ -3576,8 +4298,11 @@ export class LSPService {
3576
4298
  // Push already answered (settled, or published diagnostics that
3577
4299
  // its wait is about to settle on) — nothing to confirm, no sync
3578
4300
  // request goes out.
4301
+ const publishedAt = primaryClient
4302
+ .getAllDiagnostics?.()
4303
+ .get(normalizedPath)?.ts;
3579
4304
  if (pushWaitSettled ||
3580
- primaryClient.getDiagnostics(filePath).length > 0) {
4305
+ (publishedAt !== undefined && publishedAt > markedAtMs)) {
3581
4306
  return new Promise(() => { });
3582
4307
  }
3583
4308
  try {
@@ -4232,9 +4957,7 @@ export class LSPService {
4232
4957
  reason: reasons.join(", ") || "scanner coverage gap",
4233
4958
  });
4234
4959
  }
4235
- logLatency({
4236
- type: "phase",
4237
- phase: "lsp_scanner_coverage_gap",
4960
+ emitBounded("lsp_scanner_coverage_gap", `${source}:${normalizedPath}`, {
4238
4961
  filePath: normalizedPath,
4239
4962
  durationMs: Date.now() - startedAt,
4240
4963
  metadata: {
@@ -4243,10 +4966,10 @@ export class LSPService {
4243
4966
  ...(brokenSkippedServerIds.length > 0 && {
4244
4967
  brokenSkippedServerIds,
4245
4968
  }),
4246
- // #1586: the deferrals this touch is actually uncovered for. The raw
4969
+ // #1586: the deferrals this touch is actually uncovered for. The
4247
4970
  // gate action keeps its own record in `lsp_notify_resync_deferred`;
4248
- // this row exists to prove a blackout, and a scanner already bound to
4249
- // these bytes is not one.
4971
+ // this row exists to prove a blackout, and a scanner already bound
4972
+ // to these bytes is not one.
4250
4973
  ...(uncoveredDeferredServerIds.length > 0 && {
4251
4974
  deferredResyncServerIds: uncoveredDeferredServerIds,
4252
4975
  }),
@@ -4492,6 +5215,49 @@ export class LSPService {
4492
5215
  this.releaseClientLease(key);
4493
5216
  }
4494
5217
  }
5218
+ /**
5219
+ * #2052: the file's nearest marker root, but ONLY when the file is outside
5220
+ * every initialized session cwd. Returns undefined (no decline) otherwise.
5221
+ *
5222
+ * The cheap registry test runs FIRST so an in-session file — the hot path —
5223
+ * pays one bounded map walk and never the `server.root()` filesystem walks.
5224
+ */
5225
+ async findOutsideProjectRoot(filePath) {
5226
+ if (!isOutsideAllSessionRoots(filePath))
5227
+ return undefined;
5228
+ const candidates = await Promise.all(getServersForFileWithConfig(filePath).map((server) => server.root(filePath)));
5229
+ return candidates.find((candidate) => Boolean(candidate));
5230
+ }
5231
+ /**
5232
+ * #2052: one bounded record per normalized foreign root per session.
5233
+ *
5234
+ * `operation` is a PARAMETER, not a constant: `touchFile` and
5235
+ * `getDiagnostics` both decline here, and a record that always claimed
5236
+ * "touchFile" would erase which call site actually refused — the
5237
+ * discriminating identity a bounded record has to preserve. The ledger's
5238
+ * rising edge is keyed on the root, so many files under one foreign root
5239
+ * still collapse to a single detailed row.
5240
+ */
5241
+ recordOutsideRootDecline(filePath, nearestRoot, operation) {
5242
+ const root = normalizeMapKey(nearestRoot);
5243
+ const sessionRoots = getSessionRootsForTelemetry()
5244
+ .map((sessionRoot) => normalizeMapKey(sessionRoot))
5245
+ .join(", ");
5246
+ emitBounded("lsp_capability_skip", root, {
5247
+ filePath: normalizeMapKey(filePath),
5248
+ durationMs: 0,
5249
+ metadata: {
5250
+ operation,
5251
+ nearestMarkerRoot: root,
5252
+ sessionRoots,
5253
+ identity: root,
5254
+ },
5255
+ }, {
5256
+ ledgerKind: "lsp-capability-skip",
5257
+ risingEdgePer: "identity",
5258
+ reason: `declined LSP root outside session cwd: ${root}`,
5259
+ });
5260
+ }
4495
5261
  /**
4496
5262
  * Get diagnostics for a file
4497
5263
  */
@@ -4527,6 +5293,11 @@ export class LSPService {
4527
5293
  }
4528
5294
  async getDiagnostics(filePath, diagnosticsMode = "full") {
4529
5295
  const normalizedPath = normalizeMapKey(filePath);
5296
+ const outsideRoot = await this.findOutsideProjectRoot(filePath);
5297
+ if (outsideRoot) {
5298
+ this.recordOutsideRootDecline(filePath, outsideRoot, "getDiagnostics");
5299
+ return [];
5300
+ }
4530
5301
  if (this.checkDestroyed()) {
4531
5302
  this.lastDiagnosticsHealth.set(normalizedPath, {
4532
5303
  health: "destroyed",
@@ -4972,7 +5743,7 @@ export class LSPService {
4972
5743
  * `executeCommand` above cannot give a render-path probe:
4973
5744
  *
4974
5745
  * - **Never spawns.** It resolves the already-connected client map the way
4975
- * `isServerAliveForFile` does, instead of routing through
5746
+ * other read-only client-map lookups do, instead of routing through
4976
5747
  * `getClientForFile` → `ensureClientForServer`. A probe that spawns a
4977
5748
  * language-server fleet to answer a question about how to RENDER a
4978
5749
  * diagnostic is a cost the caller never asked for — and under warm attach
@@ -5180,7 +5951,21 @@ export class LSPService {
5180
5951
  const activeClients = this.activeClientsForCwd(cwd, priorityServerIds);
5181
5952
  const willRenameFailures = [];
5182
5953
  const didRenameFailures = [];
5183
- const willResults = await Promise.all(activeClients.map(async ({ serverId, client }) => {
5954
+ const willRenameClients = activeClients.filter(({ serverId, client }) => {
5955
+ if (client.getOperationSupport().willRenameFiles === true)
5956
+ return true;
5957
+ recordDegradationOnce({
5958
+ kind: "lsp-capability-skip",
5959
+ subject: `${serverId}:workspace/willRenameFiles`,
5960
+ reason: client
5961
+ .getMalformedFileOperationRegistrations()
5962
+ .has("willRename")
5963
+ ? "malformed-registration"
5964
+ : "no-registration",
5965
+ });
5966
+ return false;
5967
+ });
5968
+ const willResults = await Promise.all(willRenameClients.map(async ({ serverId, client }) => {
5184
5969
  try {
5185
5970
  return {
5186
5971
  serverId,
@@ -5196,7 +5981,7 @@ export class LSPService {
5196
5981
  }
5197
5982
  }));
5198
5983
  const successfulWillResults = willResults.filter((result) => !willRenameFailures.some((failure) => failure.serverId === result.serverId));
5199
- if (activeClients.length > 0 && successfulWillResults.length === 0) {
5984
+ if (willRenameClients.length > 0 && successfulWillResults.length === 0) {
5200
5985
  throw new Error(`workspace/willRenameFiles failed for all active LSP servers: ${willRenameFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
5201
5986
  }
5202
5987
  const merged = mergeWorkspaceTextEditsByPriority(successfulWillResults);
@@ -5343,6 +6128,21 @@ export class LSPService {
5343
6128
  path.basename(newFilePath);
5344
6129
  const renameDescription = `Renamed ${relOld} → ${relNew}`;
5345
6130
  await Promise.all(activeClients.map(async ({ serverId, client }) => {
6131
+ // #1971 review: didRename has its own registration. A server that
6132
+ // never asked for didRenameFiles notifications must not receive
6133
+ // them — same chokepoint discipline as the willRename preflight.
6134
+ if (client.getOperationSupport().didRenameFiles !== true) {
6135
+ recordDegradationOnce({
6136
+ kind: "lsp-capability-skip",
6137
+ subject: `${serverId}:workspace/didRenameFiles`,
6138
+ reason: client
6139
+ .getMalformedFileOperationRegistrations()
6140
+ .has("didRename")
6141
+ ? "malformed-registration"
6142
+ : "no-registration",
6143
+ });
6144
+ return;
6145
+ }
5346
6146
  const opened = openDocuments.find((entry) => entry.serverId === serverId);
5347
6147
  // #1621: bounded for the same reason as the didClose notify above —
5348
6148
  // rename propagation is best-effort advice to servers, not a
@@ -5598,6 +6398,23 @@ export class LSPService {
5598
6398
  // never proved it can answer diagnostics — that's a failed warm-up. A
5599
6399
  // server with an unresolvable key never spawns here, so it can't fail this
5600
6400
  // way and is excluded (never skipped by the caller for this file).
6401
+ // #2525: same split the readiness logic above already makes — `keys[i]`
6402
+ // is `undefined` exactly when the server resolves no key for this file,
6403
+ // i.e. it will never spawn here (DenoServer without a deno.json). The
6404
+ // `lsp_sweep_warmup_*` records must not claim to have warmed it. Free:
6405
+ // `keys` is already resolved, so this costs no filesystem work.
6406
+ const rootedServerIds = [];
6407
+ const unrootedServerIds = [];
6408
+ for (let i = 0; i < servers.length; i++) {
6409
+ if (keys[i] !== undefined)
6410
+ rootedServerIds.push(servers[i].id);
6411
+ else
6412
+ unrootedServerIds.push(servers[i].id);
6413
+ }
6414
+ const unrootedCandidates = {
6415
+ count: unrootedServerIds.length,
6416
+ ids: unrootedServerIds,
6417
+ };
5601
6418
  const stillColdServerIds = () => {
5602
6419
  const cold = [];
5603
6420
  for (let i = 0; i < servers.length; i++) {
@@ -5617,7 +6434,12 @@ export class LSPService {
5617
6434
  phase: "lsp_sweep_warmup_start",
5618
6435
  filePath: representativeFile,
5619
6436
  durationMs: 0,
5620
- metadata: { serverIds: servers.map((s) => s.id), timeoutMs, attempt },
6437
+ metadata: {
6438
+ serverIds: rootedServerIds,
6439
+ unrootedCandidates,
6440
+ timeoutMs,
6441
+ attempt,
6442
+ },
5621
6443
  });
5622
6444
  const warmupAttempt = this.touchFile(representativeFile, content, {
5623
6445
  diagnostics: "document",
@@ -5667,7 +6489,8 @@ export class LSPService {
5667
6489
  filePath: representativeFile,
5668
6490
  durationMs: Date.now() - startedAt,
5669
6491
  metadata: {
5670
- serverIds: servers.map((s) => s.id),
6492
+ serverIds: rootedServerIds,
6493
+ unrootedCandidates,
5671
6494
  timeoutMs,
5672
6495
  attempt,
5673
6496
  coldServerIds,
@@ -5828,6 +6651,10 @@ export class LSPService {
5828
6651
  // mtime it was ACTUALLY scanned at (not re-stat'd after the fact, which
5829
6652
  // could race a concurrent edit).
5830
6653
  const scannedMtimeByFile = new Map();
6654
+ // #2300: size from the SAME stat call as the mtime above — zero extra
6655
+ // syscalls — so the cache write below can give `isEntryFresh` a size
6656
+ // axis alongside the mtime.
6657
+ const scannedSizeByFile = new Map();
5831
6658
  // Group files by their primary language server (#387, extracted as
5832
6659
  // `groupFilesByPrimaryServer` for #631). tsserver — and most servers — is
5833
6660
  // single-threaded per project: N concurrent touches to ONE server don't
@@ -6018,7 +6845,9 @@ export class LSPService {
6018
6845
  // microseconds with no extra event-loop tick, where an awaited
6019
6846
  // promise would insert one.
6020
6847
  try {
6021
- scannedMtimeByFile.set(filePath, nodeFs.statSync(filePath).mtimeMs);
6848
+ const scanStat = nodeFs.statSync(filePath);
6849
+ scannedMtimeByFile.set(filePath, scanStat.mtimeMs);
6850
+ scannedSizeByFile.set(filePath, scanStat.size);
6022
6851
  }
6023
6852
  catch {
6024
6853
  // Best-effort: a failed stat here just means this file won't be
@@ -6091,20 +6920,35 @@ export class LSPService {
6091
6920
  // instead of aggregating as a confirmed clean. Every route is gated here.
6092
6921
  const unconfirmedServerIds = touchCoverageGap(touchResult);
6093
6922
  const coverageGap = unconfirmedServerIds.length > 0;
6923
+ // #2052: the touch DECLINED this file — it is outside every
6924
+ // initialized session cwd, so nothing was asked and `diags` is an
6925
+ // empty placeholder, not an observation. Before this, the sweep
6926
+ // never read `skipReason`, so a declined foreign file fell through
6927
+ // as `timedOut: false` with zero diagnostics: reported CONFIRMED
6928
+ // CLEAN, and then written into the workspace cache by the record
6929
+ // loop below, which replays that false clean on every later sweep.
6930
+ const declinedOutsideRoot = touchResult?.skipReason === "outside-project-root";
6094
6931
  // #1549: the sweep verdict is per answering lane. An auxiliary gap
6095
6932
  // narrows coverage, but a primary answer remains usable; only absence of
6096
6933
  // the touch result or a primary-scoped inconclusive verdict poisons it.
6097
- const timedOut = touchResult === undefined || inconclusive;
6934
+ const timedOut = touchResult === undefined || inconclusive || declinedOutsideRoot;
6098
6935
  if (timedOut)
6099
6936
  timedOutFiles += 1;
6100
6937
  // #1618: WHY, in priority order — the outer deadline (nothing came
6101
6938
  // back at all) outranks an inner inconclusive signal, which outranks
6102
6939
  // a narrower auxiliary coverage gap.
6940
+ // #2052: `outside_project_root` is checked FIRST among the reasons a
6941
+ // touch produced no observation. A declined file cannot be
6942
+ // `inconclusive` (nothing was asked, so nothing timed out), and
6943
+ // rendering it as "didn't complete within budget" would tell the
6944
+ // reader to retry a request that will always be refused.
6103
6945
  const unconfirmedReason = touchResult === undefined
6104
6946
  ? "budget"
6105
- : inconclusive
6106
- ? "inconclusive"
6107
- : undefined;
6947
+ : declinedOutsideRoot
6948
+ ? "outside_project_root"
6949
+ : inconclusive
6950
+ ? "inconclusive"
6951
+ : undefined;
6108
6952
  // #1104 (shape 5 — AGENTS.md): the touch's content binding is an
6109
6953
  // EXPLICIT enumerable field on the wrapper now (#1179), so it survives
6110
6954
  // `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
@@ -6187,140 +7031,177 @@ export class LSPService {
6187
7031
  // is the same primitive `tools/lsp-diagnostics.ts`'s batch/directory scan
6188
7032
  // now uses for its own file list.
6189
7033
  const groupWorkers = Math.min(WORKSPACE_DIAGNOSTICS_CONCURRENCY, groups.length);
6190
- await runPerServerGroups(groups, groupWorkers, async (group) => {
6191
- if (signal?.aborted)
6192
- return;
6193
- // #1618: checked before any per-group work (pull attempt, warm-up,
6194
- // per-file loop) so a service already destroyed when this group
6195
- // starts never pays for a language-server round trip it cannot get.
6196
- if (this.checkDestroyed()) {
6197
- markServiceDestroyed(group.files);
6198
- return;
6199
- }
6200
- // Fast path: one project-wide pull for the whole group (opt-in).
6201
- if (!isWarmAttached() && workspacePullEnabled && !group.multiServer) {
6202
- const pulled = await this.tryWorkspacePull(group.files, perFileMs, cacheServedKeys);
6203
- if (pulled) {
6204
- // #1782: an explicit zero-diagnostic answer for a file this
6205
- // sweep served from cache SUPERSEDES that cached replay. Route
6206
- // it through the same result list every other answer uses, so
6207
- // the cache write below overwrites the stale entry and the
6208
- // footer reconcile in `tools/lens-diagnostics.ts` clears the
6209
- // widget rows — no second eviction path to keep in step.
6210
- for (const clean of pulled.extraClean) {
6211
- supersededCacheKeys.add(normalizeMapKey(clean.filePath));
6212
- }
6213
- for (const result of [...pulled.results, ...pulled.extraClean]) {
6214
- results.push({
6215
- ...result,
6216
- writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
6217
- });
6218
- // #671: a pull result is always confirmed (see
6219
- // `tryWorkspacePull`'s doc comment), so it's cache-eligible
6220
- // too — best-effort stat since the pull already resolved the
6221
- // diagnostics for this file some time ago.
6222
- try {
6223
- scannedMtimeByFile.set(result.filePath, nodeFs.statSync(result.filePath).mtimeMs);
7034
+ // #1723 residual, named as a deferred gap on PR #1805 and closed here:
7035
+ // the SCAN-side twin of the runner chokepoint #1805 wired in
7036
+ // `dispatcher.ts`'s `runRunner`. This sweep is the site whose COMPLETION
7037
+ // (`lsp_workspace_diagnostics`) is the `lastPhase` on #1723's own
7038
+ // 18 270 ms reproduction, so until this bracket existed the record could
7039
+ // only ever name the phase that finished BEFORE the block, never the one
7040
+ // burning the time during it.
7041
+ //
7042
+ // ONE bracket for the whole fan-out, not one per file (#2272 review F1).
7043
+ // Per-file bracketing was the first shape and it was inert for exactly
7044
+ // the case above: `phaseFinished` pushes onto a ring capped at
7045
+ // `CLOSED_BRACKET_CAP` (5, clients/latency-logger.ts), while `turn_end`
7046
+ // reads `getPhaseForWindow` AFTER the sweep returns. A 225-file sweep
7047
+ // therefore evicted the blocking file's own bracket unless the block
7048
+ // landed in the last five files, and the survivors were then rejected by
7049
+ // `MIN_PLAUSIBLE_ELAPSED_FRACTION` for being far shorter than the block
7050
+ // window. The phase string is a constant carrying no per-file identity,
7051
+ // so 225 brackets never held more information than one — they only cost
7052
+ // 225 map inserts and destroyed the ring. One bracket keeps the same
7053
+ // discriminating identity, survives to the read point, and spans the
7054
+ // block by construction.
7055
+ //
7056
+ // Paired in `finally`, per `phaseFinished`'s contract: an abandoned
7057
+ // bracket would misattribute every LATER loop_block to this sweep. The
7058
+ // leak guard is what the tests pin: emptying this `finally` reds three
7059
+ // of them. `finally` rather than a trailing call covers a throw that
7060
+ // escapes the fan-out; abort and service-destroyed both RETURN from the
7061
+ // worker callback rather than throwing, so those two paths alone would
7062
+ // not distinguish the two spellings.
7063
+ const sweepPhase = phaseStarted("lsp_workspace_diagnostics_touch");
7064
+ try {
7065
+ await runPerServerGroups(groups, groupWorkers, async (group) => {
7066
+ if (signal?.aborted)
7067
+ return;
7068
+ // #1618: checked before any per-group work (pull attempt, warm-up,
7069
+ // per-file loop) so a service already destroyed when this group
7070
+ // starts never pays for a language-server round trip it cannot get.
7071
+ if (this.checkDestroyed()) {
7072
+ markServiceDestroyed(group.files);
7073
+ return;
7074
+ }
7075
+ // Fast path: one project-wide pull for the whole group (opt-in).
7076
+ if (!isWarmAttached() && workspacePullEnabled && !group.multiServer) {
7077
+ const pulled = await this.tryWorkspacePull(group.files, perFileMs, cacheServedKeys);
7078
+ if (pulled) {
7079
+ // #1782: an explicit zero-diagnostic answer for a file this
7080
+ // sweep served from cache SUPERSEDES that cached replay. Route
7081
+ // it through the same result list every other answer uses, so
7082
+ // the cache write below overwrites the stale entry and the
7083
+ // footer reconcile in `tools/lens-diagnostics.ts` clears the
7084
+ // widget rows — no second eviction path to keep in step.
7085
+ for (const clean of pulled.extraClean) {
7086
+ supersededCacheKeys.add(normalizeMapKey(clean.filePath));
6224
7087
  }
6225
- catch {
6226
- // Not cache-eligible without a confirmed mtime.
7088
+ for (const result of [...pulled.results, ...pulled.extraClean]) {
7089
+ results.push({
7090
+ ...result,
7091
+ writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
7092
+ });
7093
+ // #671: a pull result is always confirmed (see
7094
+ // `tryWorkspacePull`'s doc comment), so it's cache-eligible
7095
+ // too — best-effort stat since the pull already resolved the
7096
+ // diagnostics for this file some time ago.
7097
+ try {
7098
+ const pullStat = nodeFs.statSync(result.filePath);
7099
+ scannedMtimeByFile.set(result.filePath, pullStat.mtimeMs);
7100
+ scannedSizeByFile.set(result.filePath, pullStat.size);
7101
+ }
7102
+ catch {
7103
+ // Not cache-eligible without a confirmed mtime.
7104
+ }
6227
7105
  }
7106
+ completed += group.files.length;
7107
+ options.onProgress?.(completed, files.length);
7108
+ return;
6228
7109
  }
6229
- completed += group.files.length;
6230
- options.onProgress?.(completed, files.length);
6231
- return;
6232
7110
  }
6233
- }
6234
- // #667: warm-check before this group's own per-file loop starts —
6235
- // cheap/no-op when the group's primary server already demonstrated
6236
- // readiness (from an earlier sweep, or an earlier group sharing the
6237
- // same server root, this session); pays one deliberate warm-up round
6238
- // trip against the group's first file only when genuinely cold. Not
6239
- // needed above the pull fast path: a `workspace/diagnostic` pull
6240
- // already covers the WHOLE group with its own generous per-server
6241
- // budget in one shot — the per-file "first N files eat individual
6242
- // timeouts" failure mode this fixes doesn't apply there.
6243
- const first = group.files[0];
6244
- if (first && !isWarmAttached()) {
6245
- const warmup = await this.ensureWarmForSweep(first, { signal });
6246
- if (signal?.aborted)
6247
- return;
6248
- // #744: the group's primary server failed warm-up (initial round
6249
- // trip + one retry both left it cold). Every per-file touch to it
6250
- // would re-pay its full timeout and time out again, dragging the
6251
- // whole sweep — the exact wedged-marksman failure mode this closes.
6252
- // So skip this group's files and record each as UNCONFIRMED
6253
- // (timedOut + skippedWarmupFailure), never as confirmed-clean `[]`:
6254
- // the group is keyed by its primary server, so a non-empty
6255
- // `failedServerIds` means that primary is the one that couldn't warm.
6256
- if (warmup.failedServerIds.length > 0) {
6257
- logLatency({
6258
- type: "phase",
6259
- phase: "lsp_sweep_group_skipped_warmup",
6260
- filePath: first,
6261
- durationMs: 0,
6262
- metadata: {
6263
- failedServerIds: warmup.failedServerIds,
6264
- // #799: distinguishes a fresh warm-up failure from a
6265
- // negative-cache hit (this sweep never re-attempted warm-up
6266
- // at all — it was already known cold from earlier this
6267
- // session).
6268
- skippedFromCache: warmup.skippedFromCache ?? false,
6269
- skippedFiles: group.files.length,
6270
- },
6271
- });
6272
- for (const filePath of group.files) {
6273
- results.push({
6274
- filePath,
6275
- diagnostics: [],
6276
- count: 0,
6277
- timedOut: true,
6278
- unconfirmedReason: "inconclusive",
6279
- skippedWarmupFailure: true,
7111
+ // #667: warm-check before this group's own per-file loop starts —
7112
+ // cheap/no-op when the group's primary server already demonstrated
7113
+ // readiness (from an earlier sweep, or an earlier group sharing the
7114
+ // same server root, this session); pays one deliberate warm-up round
7115
+ // trip against the group's first file only when genuinely cold. Not
7116
+ // needed above the pull fast path: a `workspace/diagnostic` pull
7117
+ // already covers the WHOLE group with its own generous per-server
7118
+ // budget in one shot — the per-file "first N files eat individual
7119
+ // timeouts" failure mode this fixes doesn't apply there.
7120
+ const first = group.files[0];
7121
+ if (first && !isWarmAttached()) {
7122
+ const warmup = await this.ensureWarmForSweep(first, { signal });
7123
+ if (signal?.aborted)
7124
+ return;
7125
+ // #744: the group's primary server failed warm-up (initial round
7126
+ // trip + one retry both left it cold). Every per-file touch to it
7127
+ // would re-pay its full timeout and time out again, dragging the
7128
+ // whole sweep — the exact wedged-marksman failure mode this closes.
7129
+ // So skip this group's files and record each as UNCONFIRMED
7130
+ // (timedOut + skippedWarmupFailure), never as confirmed-clean `[]`:
7131
+ // the group is keyed by its primary server, so a non-empty
7132
+ // `failedServerIds` means that primary is the one that couldn't warm.
7133
+ if (warmup.failedServerIds.length > 0) {
7134
+ logLatency({
7135
+ type: "phase",
7136
+ phase: "lsp_sweep_group_skipped_warmup",
7137
+ filePath: first,
7138
+ durationMs: 0,
7139
+ metadata: {
7140
+ failedServerIds: warmup.failedServerIds,
7141
+ // #799: distinguishes a fresh warm-up failure from a
7142
+ // negative-cache hit (this sweep never re-attempted warm-up
7143
+ // at all — it was already known cold from earlier this
7144
+ // session).
7145
+ skippedFromCache: warmup.skippedFromCache ?? false,
7146
+ skippedFiles: group.files.length,
7147
+ },
6280
7148
  });
6281
- timedOutFiles += 1;
6282
- completed += 1;
7149
+ for (const filePath of group.files) {
7150
+ results.push({
7151
+ filePath,
7152
+ diagnostics: [],
7153
+ count: 0,
7154
+ timedOut: true,
7155
+ unconfirmedReason: "inconclusive",
7156
+ skippedWarmupFailure: true,
7157
+ });
7158
+ timedOutFiles += 1;
7159
+ completed += 1;
7160
+ }
7161
+ options.onProgress?.(completed, files.length);
7162
+ return;
6283
7163
  }
6284
- options.onProgress?.(completed, files.length);
6285
- return;
7164
+ if (warmup.performedWarmup)
7165
+ options.onServerReady?.();
6286
7166
  }
6287
- if (warmup.performedWarmup)
6288
- options.onServerReady?.();
6289
- }
6290
- // #608/#621: batch-open a CHUNK of this group's files before
6291
- // waiting on diagnostics for any of them individually — see
6292
- // `preOpenGroupFiles` above. Chunking (rather than the whole
6293
- // group at once) bounds how much a single burst can dump on
6294
- // the server's request queue at real project scale, while each
6295
- // chunk's opens still land inside the debounce window and
6296
- // coalesce into one flush — see `WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE`.
6297
- for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
6298
- if (signal?.aborted)
6299
- return;
6300
- // #1618: a service destroyed WHILE this group's chunk loop was
6301
- // already running (the group-start check above can't see a
6302
- // destruction that lands mid-loop) — stop here and mark every
6303
- // file from this chunk onward, rather than letting the remaining
6304
- // chunks pay for pre-opens/touches against a torn-down service.
6305
- if (this.checkDestroyed()) {
6306
- markServiceDestroyed(group.files.slice(chunkStart));
6307
- return;
6308
- }
6309
- const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
6310
- await preOpenGroupFiles(chunk);
6311
- for (const filePath of chunk) {
6312
- // Honor cancellation between files (#341); already-collected
6313
- // results are returned as a partial.
7167
+ // #608/#621: batch-open a CHUNK of this group's files before
7168
+ // waiting on diagnostics for any of them individually — see
7169
+ // `preOpenGroupFiles` above. Chunking (rather than the whole
7170
+ // group at once) bounds how much a single burst can dump on
7171
+ // the server's request queue at real project scale, while each
7172
+ // chunk's opens still land inside the debounce window and
7173
+ // coalesce into one flush — see `WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE`.
7174
+ for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
6314
7175
  if (signal?.aborted)
6315
7176
  return;
7177
+ // #1618: a service destroyed WHILE this group's chunk loop was
7178
+ // already running (the group-start check above can't see a
7179
+ // destruction that lands mid-loop) — stop here and mark every
7180
+ // file from this chunk onward, rather than letting the remaining
7181
+ // chunks pay for pre-opens/touches against a torn-down service.
6316
7182
  if (this.checkDestroyed()) {
6317
- markServiceDestroyed(group.files.slice(group.files.indexOf(filePath)));
7183
+ markServiceDestroyed(group.files.slice(chunkStart));
6318
7184
  return;
6319
7185
  }
6320
- await processFile(filePath);
7186
+ const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
7187
+ await preOpenGroupFiles(chunk);
7188
+ for (const filePath of chunk) {
7189
+ // Honor cancellation between files (#341); already-collected
7190
+ // results are returned as a partial.
7191
+ if (signal?.aborted)
7192
+ return;
7193
+ if (this.checkDestroyed()) {
7194
+ markServiceDestroyed(group.files.slice(group.files.indexOf(filePath)));
7195
+ return;
7196
+ }
7197
+ await processFile(filePath);
7198
+ }
6321
7199
  }
6322
- }
6323
- }, signal);
7200
+ }, signal);
7201
+ }
7202
+ finally {
7203
+ phaseFinished(sweepPhase);
7204
+ }
6324
7205
  // #1618: per-reason tally alongside the flat `timedOutFiles` count (kept
6325
7206
  // for the existing `scripts/analyze-pi-lens-logs.mjs` consumer) — a
6326
7207
  // dashboard reading only the flat count can no longer mistake 81
@@ -6386,7 +7267,7 @@ export class LSPService {
6386
7267
  // boundToCurrentDisk !== false`) could never demote a pull-cached
6387
7268
  // entry. Absent `contentHash` (no binding was available) behaves
6388
7269
  // exactly as before.
6389
- workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
7270
+ workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash, scannedSizeByFile.get(result.filePath));
6390
7271
  }
6391
7272
  workspaceDiagnosticsCacheCtx.persist();
6392
7273
  // #1782: drop every cached replay a pull answered clean — returning both
@@ -6634,6 +7515,9 @@ export class LSPService {
6634
7515
  if (this.checkDestroyed())
6635
7516
  return;
6636
7517
  this.isDestroyed = true;
7518
+ for (const [key, token] of this.outstandingAuxNotifyWrites) {
7519
+ this.releaseOutstandingAuxNotifyWrite(key, token);
7520
+ }
6637
7521
  for (const key of this.typeScriptIdleTimers.keys()) {
6638
7522
  this.clearTypeScriptIdleTimer(key);
6639
7523
  }
@@ -6686,7 +7570,15 @@ export class LSPService {
6686
7570
  // #1459: every gated client is gone, so no outstanding-write record can
6687
7571
  // describe a live one. The gate's identity check already neutralises a stale
6688
7572
  // entry; clearing keeps the map honest rather than relying on that.
6689
- this.outstandingAuxNotifyWrites.clear();
7573
+ for (const [key, token] of this.outstandingAuxNotifyWrites) {
7574
+ this.releaseOutstandingAuxNotifyWrite(key, token);
7575
+ }
7576
+ this.notifyStallDemotions.clear();
7577
+ // #2358: same reasoning — a per-write latency estimate belongs to a client
7578
+ // generation, and every client is gone. `session_start` reaches this
7579
+ // through the service reset, so the adaptive wedge window re-arms with
7580
+ // the session rather than being priced by a previous one's throughput.
7581
+ this.auxNotifyDrainLatencyEwma.clear();
6690
7582
  // #1714: same reasoning — a backlog count belongs to a client generation,
6691
7583
  // and every client is gone. `session_start` reaches this through the service
6692
7584
  // reset, so the pacing state re-arms with the session rather than living for
@@ -6831,27 +7723,52 @@ export class LSPService {
6831
7723
  return [...primary, ...aux];
6832
7724
  }
6833
7725
  }
6834
- // --- Singleton Instance ---
6835
- let globalLSPService = null;
7726
+ const LSP_PROCESS_FAMILY = "lsp.service";
7727
+ const LSP_PROCESS_VERSION = 1;
7728
+ function lspProcessState() {
7729
+ let incompatibleHandoff;
7730
+ return getProcessSingleton(LSP_PROCESS_FAMILY, LSP_PROCESS_VERSION, () => ({ service: null, generationHandoff: incompatibleHandoff }), (value) => {
7731
+ // Shut down a live incompatible service before replacing its cell.
7732
+ if (!value || typeof value !== "object")
7733
+ return;
7734
+ const candidate = value;
7735
+ const previousHandoff = candidate.generationHandoff;
7736
+ const teardown = candidate.service?.shutdown
7737
+ ? candidate.service
7738
+ .shutdown({ fast: true, reason: "process_singleton_reset" })
7739
+ .catch(() => undefined)
7740
+ : undefined;
7741
+ if (previousHandoff && teardown) {
7742
+ incompatibleHandoff = Promise.allSettled([
7743
+ previousHandoff,
7744
+ teardown,
7745
+ ]).then(() => undefined);
7746
+ }
7747
+ else {
7748
+ incompatibleHandoff = previousHandoff ?? teardown;
7749
+ }
7750
+ });
7751
+ }
7752
+ function processService() {
7753
+ const state = lspProcessState();
7754
+ if (!state.service)
7755
+ state.service = new LSPService(state.generationHandoff, process.cwd());
7756
+ return state.service;
7757
+ }
6836
7758
  /**
6837
7759
  * #850: all singleton generations whose teardown is still pending. A new
6838
7760
  * generation may be allocated synchronously, but its first spawn waits on this
6839
7761
  * handoff so two generations can never own the same server/root concurrently.
6840
7762
  */
6841
- let globalLSPGenerationHandoff;
6842
7763
  export function getLSPService() {
6843
- if (!globalLSPService) {
6844
- globalLSPService = new LSPService(globalLSPGenerationHandoff);
6845
- }
6846
- return globalLSPService;
7764
+ return processService();
6847
7765
  }
6848
7766
  /**
6849
- * Cross-layer liveness seam for dispatch-side auxiliary gates. This is a
6850
- * liveness read only: it does not spawn, wait for initialization, or probe a
6851
- * binary.
7767
+ * Gate-B readiness seam. It reads only the live client map and never spawns,
7768
+ * waits for initialization, or probes a binary.
6852
7769
  */
6853
- export async function isAuxiliaryLspAlive(serverId, filePath) {
6854
- return getLSPService().isServerAliveForFile(serverId, filePath);
7770
+ export async function hasAuxiliaryLspPublishedForRoot(serverId, filePath) {
7771
+ return getLSPService().hasServerPublishedForFileRoot(serverId, filePath);
6855
7772
  }
6856
7773
  /**
6857
7774
  * Cross-layer seam (#1668) for callers outside `lsp/` (bash/write tool-result
@@ -6863,6 +7780,20 @@ export async function notifyExternalFileChange(filePath, type) {
6863
7780
  return getLSPService().notifyExternalFileChange(filePath, type);
6864
7781
  }
6865
7782
  export function resetLSPService(options = {}) {
7783
+ // #2504 review round 2 (F3): whatever the retiring service was still being
7784
+ // asked for off-hook must stop HERE, before any teardown and before the
7785
+ // `!retiringService` early return below (a `session_start` reset with no
7786
+ // live service must still retire a loop the previous session armed). This
7787
+ // is the one choke point every lifecycle path goes through —
7788
+ // `session_shutdown`, `session_start`, and the idle reset — so the
7789
+ // deferred actionable-warnings pull cannot open another file against a
7790
+ // dying service, cannot re-spawn one after the idle reset, and cannot be
7791
+ // mid-`openFile` when the loop closes (#234). Aborting spawns nothing.
7792
+ abortDeferredLspWork(`lsp service reset: ${options.reason ?? "unspecified"}`);
7793
+ // Invalidate availability publication started by the retiring service before
7794
+ // any asynchronous teardown. The launch seam checks this generation after
7795
+ // every managed lookup, install, and process launch (#2351).
7796
+ resetLspLaunchAvailabilityGeneration();
6866
7797
  // A new session must get its own classic-tsserver-repair attempt: the
6867
7798
  // guard is a process-lifetime flag (see resetClassicTsRepairGuard), so a
6868
7799
  // repair that failed transiently in an earlier session must not stay
@@ -6874,8 +7805,9 @@ export function resetLSPService(options = {}) {
6874
7805
  // hide behind a leaked/stuck guard from the generation before it.
6875
7806
  clearWorkspaceSweepHoldForSessionStart();
6876
7807
  }
6877
- const retiringService = globalLSPService;
6878
- globalLSPService = null;
7808
+ const state = lspProcessState();
7809
+ const retiringService = state.service;
7810
+ state.service = null;
6879
7811
  if (!retiringService)
6880
7812
  return;
6881
7813
  // shutdown() marks the service destroyed synchronously before its first
@@ -6884,14 +7816,14 @@ export function resetLSPService(options = {}) {
6884
7816
  // its predecessor. allSettled keeps teardown best-effort without ever
6885
7817
  // rejecting (and therefore permanently poisoning) the next generation.
6886
7818
  const teardown = retiringService.shutdown(options);
6887
- const pending = globalLSPGenerationHandoff
6888
- ? [globalLSPGenerationHandoff, teardown]
7819
+ const pending = state.generationHandoff
7820
+ ? [state.generationHandoff, teardown]
6889
7821
  : [teardown];
6890
7822
  const handoff = Promise.allSettled(pending).then(() => undefined);
6891
- globalLSPGenerationHandoff = handoff;
7823
+ state.generationHandoff = handoff;
6892
7824
  void handoff.then(() => {
6893
- if (globalLSPGenerationHandoff === handoff) {
6894
- globalLSPGenerationHandoff = undefined;
7825
+ if (state.generationHandoff === handoff) {
7826
+ state.generationHandoff = undefined;
6895
7827
  }
6896
7828
  });
6897
7829
  }