@herbertgao/pi-extensions 2026.8.6 → 2026.8.8

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 (385) hide show
  1. package/README.md +8 -6
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +2 -2
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +19 -7
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +30 -9
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +320 -22
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +79 -17
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +27 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +66 -17
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
  15. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  16. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
  17. package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
  18. package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
  19. package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
  21. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
  22. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +17 -13
  23. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
  25. package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
  26. package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
  27. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
  28. package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
  29. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
  30. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
  31. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +21 -6
  32. package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
  33. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +20 -0
  34. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +5 -4
  35. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +174 -100
  36. package/node_modules/@juicesharp/rpiv-ask-user-question/config.ts +25 -0
  37. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/configuration.md +13 -4
  38. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +5 -1
  39. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
  40. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +10 -6
  41. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
  42. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +5 -2
  43. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/zh.json +2 -2
  44. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +3 -2
  45. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +10 -0
  46. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +140 -97
  47. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +63 -28
  48. package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
  49. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
  50. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
  51. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
  52. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
  53. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
  54. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
  55. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
  56. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +86 -38
  57. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +76 -9
  58. package/node_modules/@narumitw/pi-btw/README.md +120 -111
  59. package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
  60. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
  61. package/node_modules/@narumitw/pi-btw/package.json +10 -6
  62. package/node_modules/@narumitw/pi-btw/src/btw.ts +36 -40
  63. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
  64. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
  65. package/node_modules/@narumitw/pi-btw/src/menu.ts +45 -2
  66. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
  67. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
  68. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  69. package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
  70. package/node_modules/pi-lens/CHANGELOG.md +999 -0
  71. package/node_modules/pi-lens/README.md +117 -3
  72. package/node_modules/pi-lens/config/ruff/core.toml +42 -0
  73. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
  74. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +126 -13
  75. package/node_modules/pi-lens/dist/clients/agent-nudge.js +67 -11
  76. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
  77. package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
  78. package/node_modules/pi-lens/dist/clients/bash-file-access.js +381 -13
  79. package/node_modules/pi-lens/dist/clients/biome-client.js +61 -30
  80. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
  81. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +129 -0
  82. package/node_modules/pi-lens/dist/clients/bootstrap.js +12 -1
  83. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +12 -3
  84. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +206 -0
  85. package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
  86. package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
  87. package/node_modules/pi-lens/dist/clients/cascade-format.js +20 -1
  88. package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
  89. package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
  90. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
  91. package/node_modules/pi-lens/dist/clients/dead-code-client.js +123 -31
  92. package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
  93. package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
  94. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +84 -14
  95. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
  96. package/node_modules/pi-lens/dist/clients/dependency-checker.js +25 -3
  97. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
  98. package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
  99. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +147 -33
  100. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
  101. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +52 -2
  102. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +256 -88
  103. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
  104. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +11 -3
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +12 -7
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +18 -0
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +60 -53
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -2
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +25 -2
  122. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
  123. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
  124. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +331 -55
  125. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +38 -5
  126. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +97 -19
  127. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +17 -8
  128. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +32 -4
  129. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +47 -3
  130. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +14 -4
  131. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +127 -28
  132. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
  133. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
  134. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +275 -13
  135. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +21 -6
  136. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
  137. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +734 -64
  138. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
  139. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +188 -0
  140. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +9 -0
  141. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +21 -10
  142. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -2
  143. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
  144. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
  145. package/node_modules/pi-lens/dist/clients/extension-log.js +22 -1
  146. package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
  147. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +316 -0
  148. package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
  149. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
  150. package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
  151. package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
  152. package/node_modules/pi-lens/dist/clients/git-guard.js +31 -0
  153. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
  154. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +109 -9
  155. package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
  156. package/node_modules/pi-lens/dist/clients/host-ports.js +8 -0
  157. package/node_modules/pi-lens/dist/clients/installer/index.js +1114 -100
  158. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
  159. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
  160. package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
  161. package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
  162. package/node_modules/pi-lens/dist/clients/jscpd-client.js +87 -8
  163. package/node_modules/pi-lens/dist/clients/knip-client.js +310 -10
  164. package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
  165. package/node_modules/pi-lens/dist/clients/language-profile.js +9 -2
  166. package/node_modules/pi-lens/dist/clients/latency-logger.js +440 -11
  167. package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
  168. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
  169. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  170. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +128 -8
  171. package/node_modules/pi-lens/dist/clients/lsp/client.js +1660 -164
  172. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +83 -1
  173. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
  174. package/node_modules/pi-lens/dist/clients/lsp/index.js +2423 -151
  175. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
  176. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
  177. package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
  178. package/node_modules/pi-lens/dist/clients/lsp/server.js +277 -16
  179. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
  180. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
  181. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +79 -3
  182. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +43 -0
  183. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
  184. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
  185. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
  186. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
  187. package/node_modules/pi-lens/dist/clients/mcp/session.js +10 -2
  188. package/node_modules/pi-lens/dist/clients/module-report.js +22 -1
  189. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
  190. package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
  191. package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
  192. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
  193. package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
  194. package/node_modules/pi-lens/dist/clients/pipeline.js +154 -33
  195. package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
  196. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
  197. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
  198. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +84 -14
  199. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
  200. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
  201. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +23 -9
  202. package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
  203. package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
  204. package/node_modules/pi-lens/dist/clients/project-snapshot.js +299 -0
  205. package/node_modules/pi-lens/dist/clients/quiet-window-config.js +47 -0
  206. package/node_modules/pi-lens/dist/clients/quiet-window.js +11 -25
  207. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +8 -3
  208. package/node_modules/pi-lens/dist/clients/read-guard.js +253 -10
  209. package/node_modules/pi-lens/dist/clients/resource-sampler.js +49 -17
  210. package/node_modules/pi-lens/dist/clients/reverse-deps.js +50 -11
  211. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
  212. package/node_modules/pi-lens/dist/clients/review-graph/query.js +52 -4
  213. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
  214. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
  215. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +96 -7
  216. package/node_modules/pi-lens/dist/clients/runtime-context.js +8 -1
  217. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +352 -45
  218. package/node_modules/pi-lens/dist/clients/runtime-session.js +373 -9
  219. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
  220. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +212 -79
  221. package/node_modules/pi-lens/dist/clients/runtime-turn.js +620 -67
  222. package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
  223. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
  224. package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
  225. package/node_modules/pi-lens/dist/clients/security-scan-client.js +157 -24
  226. package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
  227. package/node_modules/pi-lens/dist/clients/sg-runner.js +116 -22
  228. package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
  229. package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
  230. package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
  231. package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
  232. package/node_modules/pi-lens/dist/clients/startup-timing.js +19 -1
  233. package/node_modules/pi-lens/dist/clients/test-runner-client.js +151 -16
  234. package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
  235. package/node_modules/pi-lens/dist/clients/tool-policy.js +125 -20
  236. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
  237. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
  238. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
  239. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
  240. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
  241. package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
  242. package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
  243. package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
  244. package/node_modules/pi-lens/dist/index.js +61407 -48577
  245. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +476 -57
  246. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +132 -43
  247. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
  248. package/node_modules/pi-lens/docs/agent-guide.md +6 -1
  249. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +515 -506
  250. package/node_modules/pi-lens/docs/dispositions.md +21 -0
  251. package/node_modules/pi-lens/docs/features.md +19 -5
  252. package/node_modules/pi-lens/docs/globalconfig.md +3 -0
  253. package/node_modules/pi-lens/docs/language-coverage.md +71 -5
  254. package/node_modules/pi-lens/docs/mcp.md +2 -2
  255. package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
  256. package/node_modules/pi-lens/docs/settings.md +19 -1
  257. package/node_modules/pi-lens/docs/usage.md +1 -0
  258. package/node_modules/pi-lens/package.json +27 -10
  259. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-js-snapshot.yml +23 -0
  260. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-snapshot.yml +23 -0
  261. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/ts-void-zero-snapshot.yml +23 -0
  262. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-object-param-test.yml +16 -0
  263. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +15 -1
  264. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-compile-call-test.yml +29 -1
  265. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
  266. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
  267. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
  268. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
  269. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
  270. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +2 -0
  271. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +2 -0
  272. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +2 -1
  273. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +2 -1
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
  288. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
  289. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
  290. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
  291. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +57 -12
  292. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
  293. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
  294. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
  295. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
  296. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
  297. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
  298. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
  299. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +22 -2
  300. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +23 -2
  301. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +33 -5
  302. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +34 -8
  303. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
  304. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
  305. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
  306. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
  307. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
  308. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
  309. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +6 -1
  310. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
  311. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
  312. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
  313. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
  314. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
  315. package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
  316. package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
  317. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
  318. package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
  319. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +53 -1
  320. package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
  321. package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
  322. package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
  323. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
  324. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
  325. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
  326. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +2 -0
  327. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +80 -3
  328. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +41 -0
  329. package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
  330. package/node_modules/pi-mcp-adapter/CHANGELOG.md +36 -0
  331. package/node_modules/pi-mcp-adapter/README.md +47 -5
  332. package/node_modules/pi-mcp-adapter/cli.js +163 -1
  333. package/node_modules/pi-mcp-adapter/commands.ts +128 -22
  334. package/node_modules/pi-mcp-adapter/config.ts +25 -7
  335. package/node_modules/pi-mcp-adapter/direct-tools.ts +5 -4
  336. package/node_modules/pi-mcp-adapter/index.ts +219 -32
  337. package/node_modules/pi-mcp-adapter/init.ts +36 -4
  338. package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
  339. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
  340. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
  341. package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
  342. package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
  343. package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
  344. package/node_modules/pi-mcp-adapter/metadata-cache.ts +32 -21
  345. package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
  346. package/node_modules/pi-mcp-adapter/package.json +3 -1
  347. package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
  348. package/node_modules/pi-mcp-adapter/request-headers-command.ts +31 -37
  349. package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
  350. package/node_modules/pi-mcp-adapter/server-manager.ts +140 -2
  351. package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
  352. package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
  353. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
  354. package/node_modules/pi-mcp-adapter/types.ts +2 -0
  355. package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
  356. package/node_modules/pi-mcp-adapter/utils.ts +70 -0
  357. package/node_modules/pi-web-access/CHANGELOG.md +37 -0
  358. package/node_modules/pi-web-access/README.md +32 -13
  359. package/node_modules/pi-web-access/anysearch.ts +4 -2
  360. package/node_modules/pi-web-access/brave.ts +16 -4
  361. package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
  362. package/node_modules/pi-web-access/curator-page.ts +6 -3
  363. package/node_modules/pi-web-access/curator-server.ts +4 -1
  364. package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
  365. package/node_modules/pi-web-access/exa.ts +18 -7
  366. package/node_modules/pi-web-access/extract.ts +40 -5
  367. package/node_modules/pi-web-access/firecrawl.ts +18 -2
  368. package/node_modules/pi-web-access/gemini-search.ts +16 -4
  369. package/node_modules/pi-web-access/github-extract.ts +65 -36
  370. package/node_modules/pi-web-access/index.ts +60 -61
  371. package/node_modules/pi-web-access/openai-search.ts +17 -6
  372. package/node_modules/pi-web-access/package.json +1 -1
  373. package/node_modules/pi-web-access/page-query.ts +4 -2
  374. package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
  375. package/node_modules/pi-web-access/parallel.ts +2 -2
  376. package/node_modules/pi-web-access/pdf-extract.ts +18 -5
  377. package/node_modules/pi-web-access/query-rewrite.ts +51 -0
  378. package/node_modules/pi-web-access/serper.ts +211 -0
  379. package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
  380. package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
  381. package/node_modules/pi-web-access/summary-review.ts +44 -22
  382. package/node_modules/pi-web-access/tavily.ts +16 -4
  383. package/node_modules/pi-web-access/utils.ts +87 -0
  384. package/node_modules/pi-web-access/valyu.ts +199 -0
  385. package/package.json +14 -14
@@ -7,7 +7,7 @@
7
7
  * - Bus event integration
8
8
  * - Resource cleanup
9
9
  */
10
- import { createHash } from "node:crypto";
10
+ import { CASCADE_DIAGNOSTICS_TTL_MS } from "../cascade-types.js";
11
11
  import * as nodeFs from "node:fs";
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
@@ -16,25 +16,30 @@ import { getProjectIgnoreMatcher, isExcludedDirName, } from "../file-utils.js";
16
16
  import { recordLsp } from "../widget-state.js";
17
17
  import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
18
18
  import { detectFileRole } from "../file-role.js";
19
+ import { emitBounded } from "../bounded-telemetry.js";
19
20
  import { logLatency } from "../latency-logger.js";
20
21
  import { logSessionStart } from "../sessionstart-logger.js";
21
22
  import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "../degradation-ledger.js";
22
23
  import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
23
24
  import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
24
- import { withDeadline } from "../deadline-utils.js";
25
+ import { withDeadline, withTimeout } from "../deadline-utils.js";
26
+ import { acquireWorkspaceSweepHold, clearWorkspaceSweepHoldForSessionStart, } from "./workspace-sweep-hold.js";
27
+ import { DocumentDriftTracker, fingerprintDocumentContent, } from "./document-drift.js";
25
28
  import { isAtOrAboveHomeDir, isWindowsPath, normalizeMapKey, uriToPath, } from "../path-utils.js";
26
29
  import { recordLspMutation, } from "../lsp-mutation.js";
27
30
  import { createLSPClient } from "./client.js";
28
- import { bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, touchCoverageGap, } from "./diagnostic-binding.js";
31
+ import { auxiliaryCoverageGap, bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, hashDiagnosticContent, resolveTouchVerdict, touchCoverageGap, } from "./diagnostic-binding.js";
29
32
  import { getServersForFileWithConfig, getServerInitOverride } from "./config.js";
30
33
  import { getLanguageId } from "./language.js";
31
- import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
34
+ import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, } from "./server.js";
32
35
  import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
36
+ const WORKSPACE_ATTRIBUTION_CLIENT_CAP = 16;
33
37
  import { raceToCompletion } from "./aggregation.js";
34
38
  import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, validateWorkspaceEdit, } from "./edits.js";
35
39
  import { buildScopeKey, createWorkspaceDiagnosticsCacheContext, } from "./workspace-diagnostics-cache.js";
36
40
  import { attemptTsserverSyncDiagnostics, } from "./tsserver-sync.js";
37
41
  import { isWarmAttached, tryWarmAttachedDiagnostics, } from "../warm-attach.js";
42
+ import { getSuccessfulLspSpawnDurationMs, recordSuccessfulLspSpawn, } from "./spawn-history.js";
38
43
  function destinationUriPreservingSpelling(oldUri, oldFilePath, newFilePath) {
39
44
  const canonical = pathToFileURL(newFilePath);
40
45
  try {
@@ -160,6 +165,34 @@ const NAV_CLIENT_WAIT_TIMEOUT_MS = Math.max(0, Number.parseInt(process.env.PI_LE
160
165
  1500);
161
166
  const TOUCH_DEBOUNCE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_TOUCH_DEBOUNCE_MS ?? "1500", 10) ||
162
167
  1500);
168
+ // #1621: the rename-propagation notifies (`didClose` ahead of the rename,
169
+ // `workspace/didRenameFiles` after) share the exit-notify defect #1620 fixed —
170
+ // a notify write on a pipe that is not draining neither resolves nor rejects,
171
+ // so the bare `await` was unbounded. Rename propagation is best-effort advice
172
+ // to servers, not a correctness gate, so a wedged client's notify gets its own
173
+ // ceiling and a recorded disposition rather than stalling the `Promise.all`
174
+ // for every healthy client alongside it.
175
+ // #1621 F3: floor at 50ms, not 0 — a negative env value (e.g. "-100") is
176
+ // truthy after `Number.parseInt`, so it survives the `|| 1500` fallback and
177
+ // would otherwise reach `withDeadline`'s `ms <= 0` branch. That branch treats
178
+ // the budget as already expired and settles without ever really attempting
179
+ // the notify — a negative override would silently disable rename propagation
180
+ // instead of merely shortening its budget.
181
+ const RENAME_NOTIFY_TIMEOUT_MS = Math.max(50, Number.parseInt(process.env.PI_LENS_LSP_RENAME_NOTIFY_TIMEOUT_MS ?? "1500", 10) || 1500);
182
+ async function runRenameNotify(send, timeoutMs) {
183
+ try {
184
+ await withTimeout(send(), timeoutMs);
185
+ return { ok: true };
186
+ }
187
+ catch (err) {
188
+ const timedOut = err instanceof Error && err.message.startsWith("Timeout after");
189
+ return {
190
+ ok: false,
191
+ error: err instanceof Error ? err.message : String(err),
192
+ disposition: timedOut ? "timedOut" : "rejected",
193
+ };
194
+ }
195
+ }
163
196
  const DEFAULT_LSP_CLIENT_CEILING = 24;
164
197
  const DEFAULT_TS_IDLE_EVICT_MS = 20 * 60_000;
165
198
  export function getTypeScriptIdleEvictMs() {
@@ -181,6 +214,24 @@ export function getLspClientCeiling() {
181
214
  // project load/index before it can usefully answer ANY diagnostics request,
182
215
  // not just one file's worth of work. Env-tunable like every other wait budget
183
216
  // in this file.
217
+ /**
218
+ * #1783: does this client hold the document open? Tolerates a client that does
219
+ * not implement `isDocumentOpen` (a test double, or a future client shape) by
220
+ * answering "no" — the drift backstop then drops the record rather than
221
+ * resyncing a view it cannot confirm exists.
222
+ */
223
+ function documentIsOpenOn(client, filePath) {
224
+ const probe = client
225
+ .isDocumentOpen;
226
+ if (typeof probe !== "function")
227
+ return false;
228
+ try {
229
+ return probe.call(client, filePath) === true;
230
+ }
231
+ catch {
232
+ return false;
233
+ }
234
+ }
184
235
  function warmupTimeoutMs() {
185
236
  const raw = Number.parseInt(process.env.PI_LENS_LSP_WARMUP_TIMEOUT_MS ?? "", 10);
186
237
  return Number.isFinite(raw) && raw > 0 ? raw : 20_000;
@@ -254,7 +305,6 @@ const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(0, Number.parseInt(process.
254
305
  // Once the fastest client has diagnostics, remaining clients get this window before
255
306
  // we proceed with whatever results are ready. 0 disables early-unblock.
256
307
  const EARLY_UNBLOCK_GRACE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_EARLY_UNBLOCK_GRACE_MS ?? "400", 10) || 400);
257
- const CASCADE_DIAGNOSTICS_TTL_MS = 240_000;
258
308
  function mergeLspDiagnostics(diagnostics) {
259
309
  const merged = [];
260
310
  const seen = new Set();
@@ -399,6 +449,40 @@ function notifyWriteBudgetMs() {
399
449
  const raw = Number(process.env.PI_LENS_LSP_NOTIFY_BUDGET_MS);
400
450
  return Number.isFinite(raw) && raw > 0 ? raw : 2000;
401
451
  }
452
+ // #1459: how long ONE auxiliary notify write may stay outstanding before the
453
+ // server counts as wedged rather than merely slow. A scanner whose per-file work
454
+ // exceeds the write budget is normal (opengrep routinely needs >2s on a large
455
+ // file) and must not be demoted for it — the gate defers the next write instead.
456
+ // A write still unaccepted after this window is a different animal: nothing is
457
+ // draining that stdin, so the server is demoted through the existing breaker.
458
+ // Expressed as a multiple of the write budget so tuning one moves both.
459
+ const NOTIFY_WEDGED_BUDGET_MULTIPLIER = 5;
460
+ function notifyWedgedMs() {
461
+ return notifyWriteBudgetMs() * NOTIFY_WEDGED_BUDGET_MULTIPLIER;
462
+ }
463
+ // #1714: how many document notifies one auxiliary may hold UNACKNOWLEDGED
464
+ // before the next notify has to prove the server drained its input.
465
+ //
466
+ // #1459's gate bounds CONCURRENT writes to one per auxiliary. That stops a
467
+ // simultaneous fan-out, but a `lens_diagnostics mode=full` sweep is mostly
468
+ // SEQUENTIAL — one file after another inside a server group (#387) — so every
469
+ // write is alone in flight and the gate never engages. Each write still resolves
470
+ // as soon as the pipe accepts the bytes, not when the scanner has read them, so
471
+ // the sweep can hand a single-threaded scanner hundreds of full re-parses faster
472
+ // than it consumes them. ast-grep stalled and had to be force-killed twice in
473
+ // two full-scan exposures. Counting unacknowledged notifies bounds the BACKLOG
474
+ // the sweep is allowed to build, which pipe-level backpressure alone does not.
475
+ const AUX_NOTIFY_INFLIGHT_DEFAULT = 8;
476
+ function auxNotifyInflightLimit(info) {
477
+ const perServer = info.notifyInflightLimit;
478
+ if (typeof perServer === "number" && Number.isFinite(perServer) && perServer > 0) {
479
+ return Math.floor(perServer);
480
+ }
481
+ const raw = Number(process.env.PI_LENS_LSP_AUX_NOTIFY_INFLIGHT);
482
+ return Number.isFinite(raw) && raw > 0
483
+ ? Math.floor(raw)
484
+ : AUX_NOTIFY_INFLIGHT_DEFAULT;
485
+ }
402
486
  // Budget for one project-wide `workspace/diagnostic` pull (#387 Item 2). Larger
403
487
  // than a per-file wait — it's a single request but scans the whole program —
404
488
  // yet bounded so a hung server still falls back to the per-file path.
@@ -471,9 +555,15 @@ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceD
471
555
  continue;
472
556
  await walk(full);
473
557
  }
474
- else if (entry.isFile() &&
475
- !ignoreMatcher.isIgnored(full, false) &&
476
- getServersForFileWithConfig(full).length > 0) {
558
+ else if (
559
+ // #1974: the getServersForFileWithConfig lookup (an in-memory
560
+ // extension/pathFilter match against the registered LSP servers) is
561
+ // cheap relative to isIgnored's per-call minimatch pattern compile,
562
+ // so it gates first — the same order-independent shape as the four
563
+ // walkers fixed for #1974.
564
+ entry.isFile() &&
565
+ getServersForFileWithConfig(full).length > 0 &&
566
+ !ignoreMatcher.isIgnored(full, false)) {
477
567
  files.push(full);
478
568
  }
479
569
  }
@@ -492,6 +582,21 @@ export class LSPService {
492
582
  /** Server/root pairs that already emitted unavailable for the current occurrence. */
493
583
  unavailableLogged = new Set();
494
584
  optionalDisabled = new Set();
585
+ /**
586
+ * #1934 review F1: what the last COMPLETED `spawnClient` call for a
587
+ * (server, root) key decided, written by that call at every point it
588
+ * returns without a client. This is a direct signal, deliberately NOT an
589
+ * inference from breaker state: the "binary unavailable while installs are
590
+ * disabled" branch sets a cooldown yet is a POLICY decline by its own
591
+ * comment, so reading the cooldown mislabels it as a server failure.
592
+ *
593
+ * Not a latch, and nothing re-arms it at `session_start`. Every read sits
594
+ * in the same microtask as the `await` of the spawn promise that just
595
+ * wrote it, so a stale entry is unreachable: a read is always preceded by
596
+ * its own attempt's write. Cardinality matches `state.clients` — one entry
597
+ * per (server, root) — and a successful spawn deletes its entry.
598
+ */
599
+ lastSpawnVerdict = new Map();
495
600
  /** Consecutive failure counts for exponential backoff circuit breaker */
496
601
  failureCounts = new Map();
497
602
  /**
@@ -547,6 +652,13 @@ export class LSPService {
547
652
  * those falls through to a fresh check rather than serving a stale result.
548
653
  */
549
654
  lastKnownContentHash = new Map();
655
+ /**
656
+ * #1783: what content actually landed on a language server, per document.
657
+ * The drift sweep compares disk (size, mtime) against these records and
658
+ * resynchronizes anything an untracked edit moved behind the server's back.
659
+ * See `document-drift.ts` for the key design and the pacing rules.
660
+ */
661
+ documentDrift = new DocumentDriftTracker();
550
662
  /**
551
663
  * #1095: lazily verifies a stored {@link DiagnosticBinding} against current
552
664
  * disk bytes, memoizing the disk fingerprint per (file, mtime) so repeated
@@ -577,6 +689,42 @@ export class LSPService {
577
689
  * successful write clears its entry.
578
690
  */
579
691
  notifyWriteBackpressureStreak = new Map();
692
+ /**
693
+ * #1714: unacknowledged auxiliary document notifies per server key
694
+ * ("serverId:normalizedRoot" — the same identity as every other gate here).
695
+ *
696
+ * `unacked` counts notifies ISSUED to this client that the server has not yet
697
+ * been proven to have PROCESSED. It rises with each write the sweep hands over
698
+ * and falls only when a drain barrier round-trips (see {@link paceAuxNotify}).
699
+ * `drain` holds the one in-flight barrier so a burst shares a single
700
+ * round-trip instead of each touch sending its own.
701
+ *
702
+ * `gateOpen` is the fail-open latch. A scanner that will not answer the
703
+ * barrier inside the caller's budget has stopped being a pacing problem and
704
+ * become a stall, which #743's write deadline, streak and wedge timer already
705
+ * own — and they own it by DEMOTING and respawning, which pacing can never do.
706
+ * Once latched, this gate steps aside for the rest of the client's life and
707
+ * every notify takes the pre-#1714 path. It re-arms on the only event that
708
+ * means the stall is over: a new client generation, which gets a new record.
709
+ *
710
+ * Cleared wholesale by the service teardown (`session_start` runs through it),
711
+ * by {@link demoteForNotifyStall}, and per key whenever the client identity
712
+ * changes, so no count can outlive the client it describes.
713
+ */
714
+ auxNotifyInflight = new Map();
715
+ /**
716
+ * #1459: the ONE outstanding auxiliary notify write per server key
717
+ * ("serverId:normalizedRoot"). A `reopenOnResync` scanner re-parses the whole
718
+ * file on every `didOpen`, so a `clientScope: "all"` sweep that fans out
719
+ * across a neighbour set pushes N full re-scans at it inside a few
720
+ * milliseconds; its stdin stops draining and the #743 write deadline expires
721
+ * for each one, which walked the breaker open in three touches. The gate keeps
722
+ * a sweep to one in-flight resync per auxiliary: while one is outstanding the
723
+ * next touch DEFERS its write and reports the server as uncovered instead of
724
+ * adding to the flood. `startedAt` dates the outstanding write so a write that
725
+ * never lands is still demoted (see {@link demoteForNotifyStall}).
726
+ */
727
+ outstandingAuxNotifyWrites = new Map();
580
728
  /** LRU clock for capacity eviction, keyed by the canonical server/root key. */
581
729
  clientLastUsedAt = new Map();
582
730
  /**
@@ -946,6 +1094,22 @@ export class LSPService {
946
1094
  return;
947
1095
  }
948
1096
  this.notifyWriteBackpressureStreak.delete(key);
1097
+ this.demoteForNotifyStall(key, entry, filePath, {
1098
+ consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
1099
+ });
1100
+ }
1101
+ /**
1102
+ * The demotion itself, shared by the #743 consecutive-timeout streak and the
1103
+ * #1459 wedged-write rule. Both mean the same thing — this client's input path
1104
+ * is not moving — and both need the same teardown.
1105
+ */
1106
+ demoteForNotifyStall(key, entry, filePath, reason) {
1107
+ this.notifyWriteBackpressureStreak.delete(key);
1108
+ this.outstandingAuxNotifyWrites.delete(key);
1109
+ // #1714: the demoted client is torn down, so its backlog count describes a
1110
+ // process that no longer exists. Leaving it would make the replacement start
1111
+ // at the ceiling and pay a barrier on its first file.
1112
+ this.auxNotifyInflight.delete(key);
949
1113
  this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
950
1114
  void entry.client.shutdown().catch(() => { });
951
1115
  this.state.clients.delete(key);
@@ -961,10 +1125,432 @@ export class LSPService {
961
1125
  metadata: {
962
1126
  serverId: entry.info.id,
963
1127
  cooldownMs: BROKEN_BASE_COOLDOWN_MS,
964
- consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
1128
+ ...reason,
965
1129
  },
966
1130
  });
967
1131
  }
1132
+ /**
1133
+ * #1459: the caller's write deadline is a LATENCY bound, not a health verdict.
1134
+ * A scanner whose `didOpen` lands a second after we stopped waiting is slow,
1135
+ * not broken, so its late success retracts the timeout it was charged for.
1136
+ * Without this, three slow-but-healthy scans in a row opened the breaker and
1137
+ * blacked out the security lane for 15 s.
1138
+ */
1139
+ retractNotifyWriteBackpressure(key, serverId, filePath, outstandingMs, client) {
1140
+ // Generation-checked, exactly like the gate: a predecessor's late landing
1141
+ // must not decrement its SUCCESSOR's streak and mask a real stall.
1142
+ if (this.state.clients.get(key) !== client)
1143
+ return;
1144
+ const streak = this.notifyWriteBackpressureStreak.get(key);
1145
+ if (!streak)
1146
+ return;
1147
+ const streakAfter = streak - 1;
1148
+ if (streakAfter <= 0)
1149
+ this.notifyWriteBackpressureStreak.delete(key);
1150
+ else
1151
+ this.notifyWriteBackpressureStreak.set(key, streakAfter);
1152
+ logLatency({
1153
+ type: "phase",
1154
+ phase: "lsp_notify_write_late_landed",
1155
+ filePath: normalizeMapKey(filePath),
1156
+ durationMs: outstandingMs,
1157
+ metadata: { serverId, outstandingMs, streakAfter },
1158
+ });
1159
+ }
1160
+ /**
1161
+ * #1714: record that one more document notify went to this auxiliary.
1162
+ *
1163
+ * Counted at ISSUE time, not on the write's settle: the backlog the sweep
1164
+ * builds is what the server still has to read, and a write that has not landed
1165
+ * yet is part of it. A client-identity change resets the count, because a
1166
+ * respawned server carries none of its predecessor's backlog.
1167
+ */
1168
+ noteAuxNotifyIssued(key, client) {
1169
+ const record = this.auxNotifyInflight.get(key);
1170
+ if (record && record.client === client) {
1171
+ record.unacked += 1;
1172
+ return;
1173
+ }
1174
+ this.auxNotifyInflight.set(key, { client, unacked: 1 });
1175
+ }
1176
+ /**
1177
+ * #1714: is this auxiliary already holding as many documents as it may?
1178
+ *
1179
+ * Read by the sweep's PRE-OPEN pass, which writes `didOpen` directly instead
1180
+ * of going through `touchFile` and so meets neither the #1459 slot gate nor
1181
+ * the drain barrier. Pre-opening is explicitly best-effort — the file's own
1182
+ * `touchFile` opens it a moment later, through the barrier — so a backlogged
1183
+ * scanner is simply left out of the warm-up burst rather than handed a second
1184
+ * copy of every file in the chunk.
1185
+ */
1186
+ auxNotifyBacklogAtCeiling(key, entry) {
1187
+ const record = this.auxNotifyInflight.get(key);
1188
+ if (!record || record.client !== entry.client)
1189
+ return false;
1190
+ // Latched open: this scanner is a stall, not a pacing problem, and the
1191
+ // breaker owns it. Step aside here too rather than half-throttling it.
1192
+ if (record.gateOpen)
1193
+ return false;
1194
+ return record.unacked >= auxNotifyInflightLimit(entry.info);
1195
+ }
1196
+ /**
1197
+ * #1714: hold the next notify until this auxiliary has proven it PROCESSED the
1198
+ * ones already sent. Returns when the caller may write — it never refuses.
1199
+ *
1200
+ * Under the limit there is nothing to prove, so the common case returns without
1201
+ * awaiting anything and the sweep runs at full speed. At the limit the gate
1202
+ * sends ONE request round-trip (`pingLiveness`, #1277,
1203
+ * clients/lsp/client.ts:2577).
1204
+ *
1205
+ * WHY A REPLY PROVES PROCESSING, measured rather than assumed. ast-grep-lsp is
1206
+ * tower-lsp-server and drains its message stream in order on one task, so a
1207
+ * request written after N `didOpen`s is answered after those N are scanned.
1208
+ * Live probe against the real binary, 30 real repository files:
1209
+ *
1210
+ * idle `workspace/symbol` reply 0 ms
1211
+ * after 30 didOpens 2263 ms, 29 of 30 publishes already in
1212
+ * all 30 processed 2330 ms
1213
+ *
1214
+ * idle `textDocument/hover` reply 1 ms
1215
+ * after 30 didOpens 2086 ms, 29 of 30 publishes already in
1216
+ *
1217
+ * Idle-zero to loaded-seconds, landing within one document of the whole
1218
+ * backlog, is the ordering property this gate needs; one document of slack is
1219
+ * immaterial against a ceiling of 4 to 8.
1220
+ *
1221
+ * A server that answered requests off a SEPARATE task would not give this
1222
+ * proof. What keeps that server safe is NOT the fail-open latch below: the
1223
+ * latch never arms for it. Such a server answers the barrier instantly,
1224
+ * `unacked` resets, and the gate stays inert for the whole sweep. Safety comes
1225
+ * from the outcome that inertness produces — the notify sequence is exactly
1226
+ * the pre-#1714 one, and #743's write deadline, backpressure streak and wedge
1227
+ * timer own the stall the same way they did before this change. The throttle
1228
+ * buys such a server nothing; it also costs it nothing.
1229
+ *
1230
+ * The wait is bounded by the CALLER's remaining budget, never by a schedule of
1231
+ * its own: every waiter gives the shared round-trip only `waitMs`, so a caller
1232
+ * that asked for a 1 s touch still gets one.
1233
+ *
1234
+ * A waiter whose budget runs out does NOT defer the file. It latches the gate
1235
+ * open and falls through to the write. Pacing exists to stop a healthy-but-slow
1236
+ * scanner drowning; a scanner that will not answer at all is a stall, and #743's
1237
+ * write deadline, backpressure streak and wedge timer already own that case —
1238
+ * they demote and respawn it, which is the self-heal that recovered the live
1239
+ * session. Deferring instead withheld the write, accrued no strike, and left
1240
+ * the sweep with no exit.
1241
+ *
1242
+ * Fails OPEN for a client with no liveness round-trip: unmeasurable is not the
1243
+ * same as backlogged, and the codebase already reads this capability as
1244
+ * `pingLiveness?.() ?? true` (clients/lsp/client.ts:281). Every real client
1245
+ * provides it.
1246
+ */
1247
+ async paceAuxNotify(key, entry, filePath, waitMs, context) {
1248
+ const record = this.auxNotifyInflight.get(key);
1249
+ if (!record)
1250
+ return;
1251
+ if (record.client !== entry.client) {
1252
+ // A previous generation's backlog says nothing about this client.
1253
+ this.auxNotifyInflight.delete(key);
1254
+ return;
1255
+ }
1256
+ // Already handed to the breaker: no barrier, no wait, no extra cost per
1257
+ // file. This is what stops a stalled scanner turning every remaining file
1258
+ // into a fresh full-budget wait.
1259
+ if (record.gateOpen)
1260
+ return;
1261
+ const limit = auxNotifyInflightLimit(entry.info);
1262
+ if (record.unacked < limit)
1263
+ return;
1264
+ const ping = entry.client.pingLiveness;
1265
+ if (!ping) {
1266
+ record.unacked = 0;
1267
+ return;
1268
+ }
1269
+ if (waitMs <= 0) {
1270
+ this.openAuxNotifyGate(key, entry, filePath, context, {
1271
+ unacked: record.unacked,
1272
+ limit,
1273
+ waitMs,
1274
+ durationMs: 0,
1275
+ });
1276
+ return;
1277
+ }
1278
+ if (!record.drain) {
1279
+ const startedAt = Date.now();
1280
+ const outstanding = record.unacked;
1281
+ const client = entry.client;
1282
+ const barrier = (async () => {
1283
+ let drained = false;
1284
+ try {
1285
+ drained = await ping.call(client, waitMs);
1286
+ }
1287
+ catch {
1288
+ // A ping that throws proves nothing about the backlog; treat it as
1289
+ // undrained rather than waving the next write through.
1290
+ drained = false;
1291
+ }
1292
+ const current = this.auxNotifyInflight.get(key);
1293
+ if (current === record && current.client === client) {
1294
+ current.drain = undefined;
1295
+ // Subtract the snapshot rather than zeroing: a concurrent touch may
1296
+ // have issued a write after this round-trip was sent, and that write
1297
+ // is still unacknowledged.
1298
+ if (drained)
1299
+ current.unacked = Math.max(0, current.unacked - outstanding);
1300
+ // Deliberately no `else` latch here. A barrier is only ever created
1301
+ // by a call that goes on to await it, so a negative result always
1302
+ // reaches a waiter — as `false`, or as that waiter's own timeout —
1303
+ // and the waiter is what latches. A second latch here would be
1304
+ // unreachable, and no test could hold it honest.
1305
+ }
1306
+ this.noteDrainBarrierOutcome(key, entry, filePath, context, {
1307
+ unacked: outstanding,
1308
+ limit,
1309
+ waitMs,
1310
+ durationMs: Date.now() - startedAt,
1311
+ outcome: drained ? "drained" : "stalled",
1312
+ });
1313
+ return drained;
1314
+ })();
1315
+ barrier.catch(() => { });
1316
+ record.drain = barrier;
1317
+ record.stallLogged = false;
1318
+ }
1319
+ // Each waiter spends only its OWN remaining budget on the shared barrier.
1320
+ const waitStartedAt = Date.now();
1321
+ const drained = await withDeadline(record.drain, {
1322
+ ms: waitMs,
1323
+ onTimeout: "undefined",
1324
+ onReject: "undefined",
1325
+ });
1326
+ if (drained !== true) {
1327
+ // The waiter gave up before the round-trip answered. Latch open and let
1328
+ // the caller write: a barrier whose ping never answers would otherwise
1329
+ // tax every remaining file a full budget for nothing.
1330
+ this.openAuxNotifyGate(key, entry, filePath, context, {
1331
+ unacked: record.unacked,
1332
+ limit,
1333
+ waitMs,
1334
+ durationMs: Date.now() - waitStartedAt,
1335
+ });
1336
+ }
1337
+ }
1338
+ /**
1339
+ * #1714: stop pacing this client and hand it to the breaker.
1340
+ *
1341
+ * Latched, not cooled down — a timer here would be a schedule of this gate's
1342
+ * own, and it would race the caller's cadence. The latch clears only when the
1343
+ * record does: a demotion ({@link demoteForNotifyStall}), a client-identity
1344
+ * change, or the service teardown. Every one of those means a new server
1345
+ * process, which is the only event that makes the old backlog meaningless.
1346
+ */
1347
+ openAuxNotifyGate(key, entry, filePath, context, detail) {
1348
+ const record = this.auxNotifyInflight.get(key);
1349
+ if (record && record.client === entry.client) {
1350
+ // `drain` is deliberately left alone. Once the gate is open nothing
1351
+ // reads it again — `paceAuxNotify` returns above the barrier — and the
1352
+ // round-trip's own settle handler clears it when it finally answers.
1353
+ // Clearing it here would be a write no test could hold honest.
1354
+ record.gateOpen = true;
1355
+ }
1356
+ this.noteDrainBarrierOutcome(key, entry, filePath, context, {
1357
+ ...detail,
1358
+ outcome: "stalled",
1359
+ });
1360
+ }
1361
+ /**
1362
+ * #1714: emit at most ONE record per barrier. A stalled barrier can be
1363
+ * abandoned by every file left in the sweep, and one row per file would turn a
1364
+ * single stuck scanner into hundreds of identical records.
1365
+ */
1366
+ noteDrainBarrierOutcome(key, entry, filePath, context, detail) {
1367
+ const record = this.auxNotifyInflight.get(key);
1368
+ if (detail.outcome === "stalled" && record) {
1369
+ if (record.stallLogged)
1370
+ return;
1371
+ record.stallLogged = true;
1372
+ }
1373
+ this.logDrainBarrier(key, entry, filePath, context, detail);
1374
+ }
1375
+ /**
1376
+ * #1714: one row per barrier, not per waiter — a burst of N notifies produces
1377
+ * at most one round-trip and one record, so the volume is bounded by the sweep
1378
+ * divided by the limit. Names the server and the file that hit the ceiling, so
1379
+ * "which scanner is falling behind" survives aggregation.
1380
+ */
1381
+ logDrainBarrier(key, entry, filePath, context, detail) {
1382
+ if (detail.outcome === "stalled") {
1383
+ incrementDegradationCount({
1384
+ kind: "lsp-notify-inflight-stall",
1385
+ subject: `${entry.info.id}:${normalizeMapKey(filePath)}`,
1386
+ reason: `notify barrier stalled with ${detail.unacked} unacknowledged writes`,
1387
+ });
1388
+ }
1389
+ logLatency({
1390
+ type: "phase",
1391
+ phase: "lsp_notify_inflight_barrier",
1392
+ filePath: normalizeMapKey(filePath),
1393
+ durationMs: detail.durationMs,
1394
+ metadata: {
1395
+ serverId: entry.info.id,
1396
+ clientKey: key,
1397
+ source: context.source,
1398
+ clientScope: context.clientScope,
1399
+ ...detail,
1400
+ },
1401
+ });
1402
+ }
1403
+ /**
1404
+ * #1459: take this auxiliary's resync slot, waiting up to `budgetMs` for it.
1405
+ *
1406
+ * The gate is a QUEUE, not a drop: a healthy scanner accepts a `didOpen` in
1407
+ * milliseconds, so a sweep's neighbours take their turns one after another and
1408
+ * every file still gets scanned — what the gate prevents is N simultaneous
1409
+ * full re-scans flooding one stdin. Only a scanner that cannot accept a write
1410
+ * inside the budget makes a waiter give up, and giving up is reported as a
1411
+ * coverage gap rather than pushed anyway.
1412
+ *
1413
+ * The slot is CLAIMED SYNCHRONOUSLY: the check and the insert sit in one
1414
+ * uninterrupted run of statements, and the returned handle owns the entry. A
1415
+ * version that returned "the slot looks free, go write" and let the caller
1416
+ * insert its own record after an `await` was not a gate at all — when the
1417
+ * holder's write landed, every waiter woke in the same microtask batch, each
1418
+ * read an empty map, and all of them wrote at once (measured: one write at t=0,
1419
+ * then a five-wide flood at t=50 for six touches). That is #1459's own root
1420
+ * cause rebuilt inside the fix for it.
1421
+ *
1422
+ * Returns a handle with `release()` (call on the write's settle, idempotent), or
1423
+ * a verdict naming how long the blocking write has been outstanding.
1424
+ */
1425
+ async claimAuxNotifySlot(clientKey, entry, filePath, budgetMs) {
1426
+ const deadline = Date.now() + budgetMs;
1427
+ for (;;) {
1428
+ const outstanding = this.outstandingAuxNotifyWrites.get(clientKey);
1429
+ // A record left behind by a PREVIOUS client generation (evicted,
1430
+ // respawned) says nothing about this client's stdin — drop it, so a stale
1431
+ // entry can never starve a healthy server.
1432
+ if (outstanding && outstanding.client !== entry.client) {
1433
+ this.outstandingAuxNotifyWrites.delete(clientKey);
1434
+ }
1435
+ else if (outstanding) {
1436
+ const outstandingMs = Date.now() - outstanding.startedAt;
1437
+ const remainingMs = deadline - Date.now();
1438
+ if (remainingMs <= 0)
1439
+ return { outstandingMs };
1440
+ // `settled` never rejects, so this only resolves or times out.
1441
+ await withDeadline(outstanding.settled, {
1442
+ ms: remainingMs,
1443
+ onTimeout: "undefined",
1444
+ onReject: "undefined",
1445
+ });
1446
+ continue;
1447
+ }
1448
+ // This client was evicted or replaced while we queued — writing to it
1449
+ // would target a retired generation. Report the gap instead.
1450
+ //
1451
+ // `!== entry.client` covers BOTH shapes, and the missing-entry one is the
1452
+ // dangerous half: eviction (idle, capacity, a #743 demotion) DELETES the
1453
+ // registry entry, so an `undefined`-exempting guard would wave the waiter
1454
+ // through to a corpse whose write resolves `true` — and `markTouched` would
1455
+ // then record this content as delivered, which is exactly the #1253
1456
+ // laundering the debounce entry must never do.
1457
+ const current = this.state.clients.get(clientKey);
1458
+ if (current !== entry.client) {
1459
+ return { outstandingMs: 0 };
1460
+ }
1461
+ // ---- No `await` from here to the `set` below: the claim is atomic. ----
1462
+ let resolveSettled;
1463
+ const settled = new Promise((resolve) => {
1464
+ resolveSettled = resolve;
1465
+ });
1466
+ const token = {
1467
+ startedAt: Date.now(),
1468
+ client: entry.client,
1469
+ settled,
1470
+ // A write nothing accepts for the whole wedge window is a dead input
1471
+ // path, not a slow scan. Armed HERE rather than checked by the next
1472
+ // waiter: inside a burst every waiter arrives within one budget, so a
1473
+ // waiter-side check could never see the wedge window elapse and a
1474
+ // wedged scanner was never demoted. Unref'd so it cannot hold a
1475
+ // one-shot host alive, and cleared on release.
1476
+ wedgeTimer: setTimeout(() => {
1477
+ if (this.outstandingAuxNotifyWrites.get(clientKey) !== token)
1478
+ return;
1479
+ this.demoteForNotifyStall(clientKey, entry, filePath, {
1480
+ outstandingMs: Date.now() - token.startedAt,
1481
+ });
1482
+ resolveSettled?.();
1483
+ }, notifyWedgedMs()),
1484
+ };
1485
+ token.wedgeTimer.unref?.();
1486
+ this.outstandingAuxNotifyWrites.set(clientKey, token);
1487
+ return {
1488
+ release: () => {
1489
+ clearTimeout(token.wedgeTimer);
1490
+ if (this.outstandingAuxNotifyWrites.get(clientKey) === token) {
1491
+ this.outstandingAuxNotifyWrites.delete(clientKey);
1492
+ }
1493
+ resolveSettled?.();
1494
+ },
1495
+ };
1496
+ }
1497
+ }
1498
+ /**
1499
+ * #1459: the auxiliary scanners that WOULD have attached to this touch but got
1500
+ * no client because their circuit breaker is open (cooldown or permanent).
1501
+ *
1502
+ * A skipped scanner said nothing about the file. Until now it also left no
1503
+ * trace on the result: it simply dropped out of `spawned`, and the touch
1504
+ * resolved `confirmation: "confirmed"` on the strength of whoever was left —
1505
+ * so a 15 s opengrep cooldown read as "scanned, clean" for every file swept
1506
+ * inside it. Naming the scanners here narrows the touch to `"partial"` instead,
1507
+ * which every coverage consumer already fails closed on (#1470).
1508
+ *
1509
+ * Deliberately AUXILIARY-only. A broken primary is already visible through the
1510
+ * `no_clients` failure kind and the demonstrated-cold path; the false-clean
1511
+ * hazard this addresses is the scanner lane, where an empty result is the
1512
+ * normal, expected answer.
1513
+ *
1514
+ * SCOPE, stated so the next reader does not assume the room is closed: this
1515
+ * covers the BREAKER doors only (cooldown + permanent). `ensureClientForServer`
1516
+ * also drops a scanner for a temporarily-unavailable command (the #1496 latch),
1517
+ * `optionalDisabled`, a spawn failure, or capacity eviction. Those are the same
1518
+ * defect class in the same lane, but they are persistent-absence states rather
1519
+ * than a transient blackout, so flagging every touch partial for them is a
1520
+ * broader behavior change than #1459 is scoped to make. Tracked separately.
1521
+ */
1522
+ async brokenSkippedAuxiliaryServerIds(filePath, clientScope, options, spawned) {
1523
+ // "primary" scope attaches no auxiliaries at all, so nothing was skipped.
1524
+ if (clientScope === "primary")
1525
+ return [];
1526
+ const enabledAuxiliaries = clientScope === "with-auxiliary"
1527
+ ? new Set(options.auxiliaryServerIds ?? [])
1528
+ : undefined;
1529
+ const attached = new Set(spawned.map((entry) => entry.info.id));
1530
+ const skipped = [];
1531
+ for (const server of getServersForFileWithConfig(filePath)) {
1532
+ if (server.role !== "auxiliary")
1533
+ continue;
1534
+ if (attached.has(server.id))
1535
+ continue;
1536
+ // An explicitly excluded server (the #584 workspace-sweep exclusion) was
1537
+ // never asked, and its findings come from its own CLI extractor — that is
1538
+ // a routing decision, not a coverage gap.
1539
+ if (options.excludeServerIds?.has(server.id))
1540
+ continue;
1541
+ if (enabledAuxiliaries && !enabledAuxiliaries.has(server.id))
1542
+ continue;
1543
+ const key = await this.demonstratedReadyKeyFor(server, filePath);
1544
+ if (!key)
1545
+ continue;
1546
+ const brokenUntil = this.state.broken.get(key);
1547
+ if (this.permanentlyBroken.has(key) ||
1548
+ (typeof brokenUntil === "number" && brokenUntil > Date.now())) {
1549
+ skipped.push(server.id);
1550
+ }
1551
+ }
1552
+ return skipped;
1553
+ }
968
1554
  activeClientsForCwd(cwd, priorityServerIds = []) {
969
1555
  const normalizedCwd = normalizeMapKey(cwd);
970
1556
  const priority = new Map(priorityServerIds.map((serverId, index) => [serverId, index]));
@@ -989,7 +1575,7 @@ export class LSPService {
989
1575
  * Get or create LSP client for a file
990
1576
  * Prevents duplicate client creation via in-flight promise tracking
991
1577
  */
992
- async getClientForFile(filePath, maxWaitMs, hardCapMs) {
1578
+ async getClientForFile(filePath, maxWaitMs, hardCapMs, resolvedRoots, waitSkipReasons) {
993
1579
  if (this.checkDestroyed())
994
1580
  return undefined;
995
1581
  // Primary selection considers language servers only — auxiliary servers
@@ -1008,12 +1594,36 @@ export class LSPService {
1008
1594
  ? Math.min(serverBaseMs, hardCapMs)
1009
1595
  : hardCapMs
1010
1596
  : serverBaseMs;
1597
+ let knownSlowResolve;
1598
+ const knownSlowSentinel = Symbol("lsp-client-wait-known-slow");
1599
+ const knownSlow = new Promise((resolve) => {
1600
+ knownSlowResolve = () => resolve(knownSlowSentinel);
1601
+ });
1602
+ const noteSpawnInFlight = (serverId) => {
1603
+ const knownDurationMs = getSuccessfulLspSpawnDurationMs(serverId);
1604
+ if (knownDurationMs !== undefined && knownDurationMs > effectiveMaxWaitMs * 2) {
1605
+ // Let a completion microtask already queued by the acquisition win
1606
+ // before the shortcut decision is observed by Promise.race.
1607
+ queueMicrotask(() => knownSlowResolve?.());
1608
+ }
1609
+ };
1011
1610
  const withBudget = async () => {
1012
1611
  if (servers.length === 0)
1013
1612
  return undefined;
1613
+ // #1934: the first server whose acquisition ERRORED, as opposed to
1614
+ // cleanly declining. Kept so a selection that served nobody still
1615
+ // says which server the pool actually tried and failed to spawn.
1616
+ let erroredServerId;
1014
1617
  // Try each matching server
1015
1618
  for (const server of servers) {
1016
- const spawned = await this.ensureClientForServer(filePath, server);
1619
+ // A box, not a `let`: control-flow analysis cannot see the callback
1620
+ // write and would narrow a plain local to its initializer.
1621
+ const acquisition = {
1622
+ outcome: "declined",
1623
+ };
1624
+ const spawned = await this.ensureClientForServer(filePath, server, resolvedRoots, noteSpawnInFlight, (reported) => {
1625
+ acquisition.outcome = reported;
1626
+ });
1017
1627
  if (spawned) {
1018
1628
  logLatency({
1019
1629
  type: "phase",
@@ -1023,10 +1633,36 @@ export class LSPService {
1023
1633
  metadata: {
1024
1634
  serverId: server.id,
1025
1635
  candidateCount: servers.length,
1636
+ // Emitted RAW, never coerced to a "safe" value: a served
1637
+ // client always reports `warm-reuse` or `cold-spawn`, so a
1638
+ // `declined` here would be a real reporting bug and must be
1639
+ // visible in the log rather than laundered into a lie.
1640
+ outcome: acquisition.outcome,
1026
1641
  },
1027
1642
  });
1028
1643
  return spawned;
1029
1644
  }
1645
+ if (acquisition.outcome === "spawn-failure") {
1646
+ erroredServerId ??= server.id;
1647
+ }
1648
+ }
1649
+ if (erroredServerId !== undefined) {
1650
+ // Same record, same denominator as the two served outcomes. Bounded
1651
+ // by the LSP breaker, not by a latch: a spawn failure always cools
1652
+ // the (server, root) key down, so the next touch takes the
1653
+ // `lsp_client_skipped_broken` early return and reports `declined`
1654
+ // instead of reaching here again until the cooldown expires.
1655
+ logLatency({
1656
+ type: "phase",
1657
+ phase: "lsp_client_selected",
1658
+ filePath,
1659
+ durationMs: 0,
1660
+ metadata: {
1661
+ serverId: erroredServerId,
1662
+ candidateCount: servers.length,
1663
+ outcome: "spawn-failure",
1664
+ },
1665
+ });
1030
1666
  }
1031
1667
  const unavailable = (await Promise.all(servers.map(async (server) => {
1032
1668
  const root = await server.root(filePath);
@@ -1071,6 +1707,7 @@ export class LSPService {
1071
1707
  try {
1072
1708
  waitResult = await Promise.race([
1073
1709
  withBudget(),
1710
+ knownSlow,
1074
1711
  new Promise((resolve) => {
1075
1712
  waitTimer = setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs);
1076
1713
  }),
@@ -1080,6 +1717,32 @@ export class LSPService {
1080
1717
  if (waitTimer)
1081
1718
  clearTimeout(waitTimer);
1082
1719
  }
1720
+ if (waitResult === knownSlowSentinel) {
1721
+ // `inFlight` is cleared in ensureClientForServer's finally block, so a
1722
+ // settled acquisition can still be present here. Re-read the published
1723
+ // clients at the decision point; a usable client outranks the sentinel.
1724
+ for (const server of servers) {
1725
+ const root = await this.resolveServerRoot(server, filePath);
1726
+ const client = root
1727
+ ? this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`)
1728
+ : undefined;
1729
+ if (client?.isAlive())
1730
+ return { client, info: server };
1731
+ }
1732
+ waitSkipReasons?.add("budget_skipped_known_slow");
1733
+ logLatency({
1734
+ type: "phase",
1735
+ phase: "lsp_client_wait_skipped",
1736
+ filePath,
1737
+ durationMs: 0,
1738
+ metadata: {
1739
+ maxWaitMs: effectiveMaxWaitMs,
1740
+ serverIds: servers.map((server) => server.id),
1741
+ reason: "budget_skipped_known_slow",
1742
+ },
1743
+ });
1744
+ return undefined;
1745
+ }
1083
1746
  if (waitResult === timeoutSentinel) {
1084
1747
  // Snapshot known client health — scan by serverId prefix (no root needed)
1085
1748
  const knownHealth = [...this.state.clients.entries()]
@@ -1109,7 +1772,7 @@ export class LSPService {
1109
1772
  * Get or create ALL LSP clients that can serve a file.
1110
1773
  * Used for diagnostics aggregation across complementary servers.
1111
1774
  */
1112
- async getClientsForFile(filePath, excludeServerIds) {
1775
+ async getClientsForFile(filePath, excludeServerIds, resolvedRoots) {
1113
1776
  const allServers = getServersForFileWithConfig(filePath);
1114
1777
  const servers = excludeServerIds && excludeServerIds.size > 0
1115
1778
  ? allServers.filter((s) => !excludeServerIds.has(s.id))
@@ -1120,7 +1783,7 @@ export class LSPService {
1120
1783
  // that fail the root check are not real spawn attempts.
1121
1784
  const roots = await Promise.all(servers.map((s) => this.resolveServerRoot(s, filePath)));
1122
1785
  const serverCountAttempted = roots.filter(Boolean).length;
1123
- const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
1786
+ const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server, resolvedRoots)));
1124
1787
  return {
1125
1788
  clients: spawned.filter((entry) => Boolean(entry)),
1126
1789
  serverCountAttempted,
@@ -1148,6 +1811,10 @@ export class LSPService {
1148
1811
  if (this.checkDestroyed())
1149
1812
  return undefined;
1150
1813
  const servers = getServersForFileWithConfig(filePath);
1814
+ // #1934: the (server, root) pairs that COULD have served this file and
1815
+ // were cold. A server with no resolvable root is not a pool miss — it
1816
+ // never had a slot to miss — so it stays out of this list.
1817
+ const missed = [];
1151
1818
  for (const server of servers) {
1152
1819
  const root = await this.resolveServerRoot(server, filePath);
1153
1820
  if (!root)
@@ -1157,6 +1824,27 @@ export class LSPService {
1157
1824
  if (existing?.isAlive()) {
1158
1825
  return { client: existing, info: server };
1159
1826
  }
1827
+ missed.push(key);
1828
+ }
1829
+ // #1934: an empty `missed` means "this file has no language server here",
1830
+ // which is the normal answer for most reads and must not be logged as a
1831
+ // pool miss. Callers run per file in the cascade quiet window and on the
1832
+ // read-expansion path, so a raw record would be a per-file log storm:
1833
+ // the ledger counts every miss exactly and only the FIRST per candidate
1834
+ // set also writes the detailed record.
1835
+ if (missed.length > 0) {
1836
+ emitBounded("lsp_warm_client_missing", missed.join(","), {
1837
+ filePath,
1838
+ durationMs: 0,
1839
+ metadata: {
1840
+ serverIds: missed.map((key) => key.slice(0, key.indexOf(":"))),
1841
+ roots: missed.map((key) => key.slice(key.indexOf(":") + 1)),
1842
+ },
1843
+ }, {
1844
+ ledgerKind: "lsp-warm-client-missing",
1845
+ risingEdgePer: "identity",
1846
+ reason: `no warm client for ${missed.join(",")}`,
1847
+ });
1160
1848
  }
1161
1849
  return undefined;
1162
1850
  }
@@ -1180,7 +1868,209 @@ export class LSPService {
1180
1868
  }
1181
1869
  return false;
1182
1870
  }
1183
- async ensureClientForServer(filePath, server) {
1871
+ /**
1872
+ * #1668: deliver a `workspace/didChangeWatchedFiles` event for a disk
1873
+ * change the client did not author through open-document sync — a bash
1874
+ * write/delete, or any other external change. `type` is the LSP
1875
+ * `FileChangeType` (1 Created, 2 Changed, 3 Deleted).
1876
+ *
1877
+ * Only reaches ALREADY-ACTIVE clients for this file's servers — a server
1878
+ * that hasn't been spawned yet has no stale cache to correct, so this
1879
+ * never spawns one just to deliver the notification. Each affected
1880
+ * client enqueues into its own #271 debounced queue, so a burst of
1881
+ * external changes still coalesces into one notification per server.
1882
+ */
1883
+ async notifyExternalFileChange(filePath, type) {
1884
+ if (this.checkDestroyed())
1885
+ return;
1886
+ for (const server of getServersForFileWithConfig(filePath)) {
1887
+ const root = await this.resolveServerRoot(server, filePath);
1888
+ if (!root)
1889
+ continue;
1890
+ const key = `${server.id}:${normalizeMapKey(root)}`;
1891
+ const existing = this.state.clients.get(key);
1892
+ if (existing?.isAlive()) {
1893
+ existing.notify.watchedFileChange(filePath, type);
1894
+ }
1895
+ }
1896
+ }
1897
+ /**
1898
+ * #1783: disk-drift backstop. Stat one batch of the documents a language
1899
+ * server currently holds, and re-push any whose bytes on disk no longer
1900
+ * match what the server was last given.
1901
+ *
1902
+ * Why this exists: an edit made outside the tracked write/edit path (a
1903
+ * bash-tool bulk edit) sends no `didChange`, so the server keeps publishing
1904
+ * pre-edit diagnostics with nothing to correct it. The resync-on-read path
1905
+ * only fires for a file the session reads again; a file that is edited and
1906
+ * never re-read stayed stale for the life of the server.
1907
+ *
1908
+ * Hot-path contract: callers fire this WITHOUT awaiting it. It is
1909
+ * rate-limited to one pass per drift-check interval (10s default),
1910
+ * stats at most 64 documents per pass, reads only the ones whose stat
1911
+ * already diverged, and issues at most 4 resyncs per pass serially. So the
1912
+ * steady-state cost on the touch path is zero, and the worst-case cost of a
1913
+ * pass is 64 stats plus up to 4 reads, off the caller's critical path.
1914
+ *
1915
+ * Never spawns: a document with no live client already holding it open has
1916
+ * no stale view to correct, so its record is dropped instead of resynced.
1917
+ */
1918
+ async sweepDocumentDrift(options = {}) {
1919
+ if (this.checkDestroyed())
1920
+ return undefined;
1921
+ return this.documentDrift.sweep({
1922
+ resync: async (filePath, content) => {
1923
+ // Reuse the normal touch path so the resync inherits the existing
1924
+ // per-server notify-write budget, the #743 backpressure demotion and
1925
+ // the client-lease machinery. diagnostics:"none" keeps it a pure
1926
+ // content push — the next genuine query gets correct diagnostics
1927
+ // because the server's view is now right, not because this call
1928
+ // waited for them.
1929
+ //
1930
+ // Scope "all" MINUS the servers that are not holding this document:
1931
+ // the resync must cover every view that is actually stale, primary
1932
+ // and auxiliary alike, and the exclusion set is what keeps that from
1933
+ // spawning anything (see serverIdsNotHoldingDocument). The write is
1934
+ // still one per held server through the same bounded notify path, so
1935
+ // the per-pass resync cap continues to bound the total writes.
1936
+ await this.touchFile(filePath, content, {
1937
+ diagnostics: "none",
1938
+ source: "drift_resync",
1939
+ clientScope: "all",
1940
+ excludeServerIds: await this.serverIdsNotHoldingDocument(filePath),
1941
+ });
1942
+ // touchFile swallows a rejected or timed-out notify write so the
1943
+ // caller's edit keeps moving, so its return proves nothing about
1944
+ // whether the heal landed. The drift record is stamped in exactly
1945
+ // one place — recordFullyCoveredSync, and only on full coverage —
1946
+ // so "did the record advance to this content" IS the answer.
1947
+ return (this.documentDrift.peek(filePath)?.fingerprint ===
1948
+ fingerprintDocumentContent(content));
1949
+ },
1950
+ holdsDocument: (filePath) => this.hasLiveClientHoldingDocument(filePath),
1951
+ onDrift: (event) => {
1952
+ // A clean re-stamp, a deleted file and a closed document are all
1953
+ // normal bookkeeping, not degradations. Only a real resync — or a
1954
+ // heal the pacing had to defer — earns a record.
1955
+ if (event.disposition === "unchanged" ||
1956
+ event.disposition === "vanished" ||
1957
+ event.disposition === "unheld") {
1958
+ return;
1959
+ }
1960
+ // Bounded record, per-file so the identity of the stuck document
1961
+ // survives aggregation. `incrementDegradationCount` keeps one entry
1962
+ // per file with an exact repeat tally instead of N entries.
1963
+ incrementDegradationCount({
1964
+ kind: "lsp-document-drift",
1965
+ subject: event.filePath,
1966
+ reason: event.disposition === "deferred"
1967
+ ? `disk drift deferred by resync pacing after ${event.driftAgeMs}ms`
1968
+ : event.disposition === "failed"
1969
+ ? `resync FAILED after ${event.driftAgeMs}ms of untracked disk drift; view still stale (${event.syncedSize}->${event.diskSize} bytes)`
1970
+ : `resynced after ${event.driftAgeMs}ms of untracked disk drift (${event.syncedSize}->${event.diskSize} bytes)`,
1971
+ });
1972
+ logLatency({
1973
+ type: "phase",
1974
+ phase: "lsp_document_drift",
1975
+ filePath: event.filePath,
1976
+ durationMs: event.driftAgeMs,
1977
+ metadata: {
1978
+ disposition: event.disposition,
1979
+ driftAgeMs: event.driftAgeMs,
1980
+ syncedSize: event.syncedSize,
1981
+ diskSize: event.diskSize,
1982
+ },
1983
+ });
1984
+ },
1985
+ }, options);
1986
+ }
1987
+ /** #1783: tracked drift-record count. Test seam for the reset conformance. */
1988
+ _driftTrackedCountForTests() {
1989
+ return this.documentDrift.size;
1990
+ }
1991
+ /**
1992
+ * #1783: does any LIVE client already hold this document open? A record for
1993
+ * a document no server holds is dropped rather than resynced, so the drift
1994
+ * backstop can never spawn a server just to correct a view nobody has.
1995
+ */
1996
+ hasLiveClientHoldingDocument(filePath) {
1997
+ for (const client of this.state.clients.values()) {
1998
+ if (!client.isAlive())
1999
+ continue;
2000
+ if (documentIsOpenOn(client, filePath))
2001
+ return true;
2002
+ }
2003
+ return false;
2004
+ }
2005
+ /**
2006
+ * #1783: server ids for this file whose client is NOT currently holding the
2007
+ * document open — the exclusion set the drift resync passes to
2008
+ * `clientScope:"all"`.
2009
+ *
2010
+ * This is what keeps "resync every server that holds it" and "never spawn"
2011
+ * compatible. `getClientsForFile` filters by this set BEFORE
2012
+ * `ensureClientForServer`, so an excluded server is never reached, and every
2013
+ * remaining server already has a live client that returns from the cache.
2014
+ * Without it, `clientScope:"all"` would spawn the file's other servers, and
2015
+ * `clientScope:"primary"` would leave an auxiliary's view stale while the
2016
+ * record claimed the file was back in sync.
2017
+ */
2018
+ async serverIdsNotHoldingDocument(filePath) {
2019
+ const exclude = new Set();
2020
+ for (const server of getServersForFileWithConfig(filePath)) {
2021
+ const root = await this.resolveServerRoot(server, filePath);
2022
+ const existing = root
2023
+ ? this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`)
2024
+ : undefined;
2025
+ if (existing?.isAlive() && documentIsOpenOn(existing, filePath))
2026
+ continue;
2027
+ exclude.add(server.id);
2028
+ }
2029
+ return exclude;
2030
+ }
2031
+ /**
2032
+ * #1783: stamp the drift record only when this touch reached EVERY server
2033
+ * that currently holds the document, and every one of those writes landed.
2034
+ *
2035
+ * `at` is the touch's START time, not the moment the write landed. The write
2036
+ * can take up to the notify budget, and an untracked edit inside that window
2037
+ * would otherwise be stamped as already-synchronized and become invisible
2038
+ * (the mtime half of the key compares against this timestamp). Starting the
2039
+ * clock at the touch — within a millisecond or two of the caller's read —
2040
+ * narrows that blind window to the caller's own read-to-touch gap.
2041
+ */
2042
+ recordFullyCoveredSync(filePath, content, targeted, allWritesLanded, at) {
2043
+ if (!allWritesLanded || targeted.length === 0)
2044
+ return;
2045
+ const targetedClients = new Set(targeted.map((entry) => entry.client));
2046
+ for (const client of this.state.clients.values()) {
2047
+ if (targetedClients.has(client))
2048
+ continue;
2049
+ if (!client.isAlive())
2050
+ continue;
2051
+ // A live client outside this touch's scope still holds the document, so
2052
+ // its view is NOT covered by this content. Recording here would claim it.
2053
+ if (documentIsOpenOn(client, filePath))
2054
+ return;
2055
+ }
2056
+ this.documentDrift.recordSynced(filePath, content, at);
2057
+ }
2058
+ /**
2059
+ * #1934 review F1: record what a `spawnClient` call decided, at the point
2060
+ * it decides it. Called on EVERY path that returns without a client, so
2061
+ * "no verdict" cannot silently mean "failed".
2062
+ *
2063
+ * `"failed"` is a server failure: the spawn or the initialize handshake
2064
+ * went wrong. `"declined"` is policy or lifecycle: host trust refused the
2065
+ * binary, the service shut down mid-spawn, or the binary is absent while
2066
+ * installs are disabled. That last one sets a breaker cooldown but is NOT
2067
+ * a failure, which is exactly why the outcome cannot be inferred from
2068
+ * breaker state.
2069
+ */
2070
+ noteSpawnVerdict(key, verdict) {
2071
+ this.lastSpawnVerdict.set(key, verdict);
2072
+ }
2073
+ async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight, onOutcome) {
1184
2074
  const handoff = this.generationHandoff;
1185
2075
  if (handoff) {
1186
2076
  await handoff;
@@ -1193,21 +2083,30 @@ export class LSPService {
1193
2083
  const root = await this.resolveServerRoot(server, filePath);
1194
2084
  if (!root || this.checkDestroyed())
1195
2085
  return undefined;
2086
+ if (server.role !== "auxiliary") {
2087
+ resolvedRoots?.set(server.id, normalizeMapKey(root));
2088
+ }
1196
2089
  const allowInstall = this.shouldAllowInstall(server.id);
1197
2090
  const normalizedRoot = normalizeMapKey(root);
1198
2091
  const key = `${server.id}:${normalizedRoot}`;
1199
2092
  const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
1200
2093
  if (server.availabilityKey &&
1201
2094
  isDirectLspCommandTemporarilyUnavailable(server.availabilityKey)) {
1202
- logLatency({
1203
- type: "phase",
1204
- phase: "lsp_client_skipped_unavailable_command",
2095
+ // #1743: during an outage this path runs once per file per touch,
2096
+ // so a raw write here is a per-file log storm. The ledger counts
2097
+ // every skip exactly, keyed on (command, file); only the first per
2098
+ // pair also writes the detailed record.
2099
+ emitBounded("lsp_client_skipped_unavailable_command", `${server.availabilityKey}:${normalizeMapKey(filePath)}`, {
1205
2100
  filePath,
1206
2101
  durationMs: 0,
1207
2102
  metadata: {
1208
2103
  serverId: server.id,
1209
2104
  command: server.availabilityKey,
1210
2105
  },
2106
+ }, {
2107
+ ledgerKind: "lsp-client-skipped-unavailable-command",
2108
+ risingEdgePer: "identity",
2109
+ reason: `command ${server.availabilityKey} temporarily unavailable`,
1211
2110
  });
1212
2111
  return undefined;
1213
2112
  }
@@ -1215,15 +2114,20 @@ export class LSPService {
1215
2114
  return undefined;
1216
2115
  }
1217
2116
  if (this.permanentlyBroken.has(key)) {
1218
- logLatency({
1219
- type: "phase",
1220
- phase: "lsp_client_skipped_broken",
2117
+ // #1743: same per-file-per-touch storm as the unavailable-command
2118
+ // skip above. Identity is (server, file) so a single wedged server
2119
+ // cannot hide which files it is refusing.
2120
+ emitBounded("lsp_client_skipped_broken", `${server.id}:${normalizeMapKey(filePath)}`, {
1221
2121
  filePath,
1222
2122
  durationMs: 0,
1223
2123
  metadata: {
1224
2124
  serverId: server.id,
1225
2125
  permanent: true,
1226
2126
  },
2127
+ }, {
2128
+ ledgerKind: "lsp-client-skipped-broken",
2129
+ risingEdgePer: "identity",
2130
+ reason: `${server.id} latched permanently broken`,
1227
2131
  });
1228
2132
  return undefined;
1229
2133
  }
@@ -1237,6 +2141,10 @@ export class LSPService {
1237
2141
  logSessionStart(`lsp warm-start ${server.id}: reused root=${root} file=${filePath}`);
1238
2142
  this.warmStartLogged.add(key);
1239
2143
  }
2144
+ // #1934: the pool paid nothing. This is the outcome the reuse rate
2145
+ // is built from, so it is reported on the ONE path that returns a
2146
+ // client without spawning.
2147
+ onOutcome?.("warm-reuse");
1240
2148
  return { client: existing, info: server };
1241
2149
  }
1242
2150
  // Dead client — was previously alive, now needs respawn
@@ -1407,15 +2315,20 @@ export class LSPService {
1407
2315
  }
1408
2316
  const brokenUntil = this.state.broken.get(key);
1409
2317
  if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
1410
- logLatency({
1411
- type: "phase",
1412
- phase: "lsp_client_skipped_broken",
2318
+ // #1743: the breaker-cooldown sibling of the permanently-broken skip
2319
+ // above, sharing its identity so an outage produces one record per
2320
+ // (server, file) rather than one per touch.
2321
+ emitBounded("lsp_client_skipped_broken", `${server.id}:${normalizeMapKey(filePath)}`, {
1413
2322
  filePath,
1414
2323
  durationMs: 0,
1415
2324
  metadata: {
1416
2325
  serverId: server.id,
1417
2326
  retryInMs: Math.max(0, brokenUntil - Date.now()),
1418
2327
  },
2328
+ }, {
2329
+ ledgerKind: "lsp-client-skipped-broken",
2330
+ risingEdgePer: "identity",
2331
+ reason: `${server.id} in breaker cooldown`,
1419
2332
  });
1420
2333
  return undefined;
1421
2334
  }
@@ -1444,8 +2357,38 @@ export class LSPService {
1444
2357
  return undefined;
1445
2358
  spawnPromise = started.promise;
1446
2359
  }
2360
+ // Announce the in-flight spawn so the caller can skip a doomed touch
2361
+ // wait. The announcement never returns a client and never
2362
+ // short-circuits. A spawn that settles inside the race window is picked
2363
+ // up by getClientForFile, which re-reads `state.clients` at the point it
2364
+ // acts on the shortcut.
2365
+ //
2366
+ // Do NOT add a `state.clients` early return here. This point sits
2367
+ // downstream of the warm-reuse path, the dead-client shutdown, the #1127
2368
+ // give-up latch, the breaker cooldown, and the #1332 idle eviction, so a
2369
+ // return here re-publishes a client every one of those already declined.
2370
+ // It also skips the `finally` below that owns the `inFlight` entry,
2371
+ // which strands the settled promise and stops the server respawning.
2372
+ onSpawnInFlight?.(server.id);
1447
2373
  try {
1448
- return await spawnPromise;
2374
+ const spawned = await spawnPromise;
2375
+ // #1934: a client here cost a process WAIT, whether this caller
2376
+ // started the spawn or joined another caller's in-flight promise.
2377
+ // Either way the selection was not served from the warm pool.
2378
+ //
2379
+ // The verdict read is synchronous and sits in the same microtask as
2380
+ // the await above, so it can only see the attempt just settled.
2381
+ onOutcome?.(spawned
2382
+ ? "cold-spawn"
2383
+ : this.lastSpawnVerdict.get(key) === "failed"
2384
+ ? "spawn-failure"
2385
+ : "declined");
2386
+ return spawned;
2387
+ }
2388
+ catch (err) {
2389
+ // A throwing spawn promise is an errored acquisition by definition.
2390
+ onOutcome?.("spawn-failure");
2391
+ throw err;
1449
2392
  }
1450
2393
  finally {
1451
2394
  if (this.state.inFlight.get(key) === spawnPromise) {
@@ -1469,6 +2412,7 @@ export class LSPService {
1469
2412
  // failure, and the user may grant trust later in the same session.
1470
2413
  if (!isLspSpawnAllowedByTrust()) {
1471
2414
  logSessionStart(`lsp spawn ${server.id}: refused — ${projectTrustDenialReason()}`);
2415
+ this.noteSpawnVerdict(key, "declined");
1472
2416
  return undefined;
1473
2417
  }
1474
2418
  const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
@@ -1488,6 +2432,7 @@ export class LSPService {
1488
2432
  // pi-lens-ignore: missing-error-propagation — best-effort kill on aborted spawn
1489
2433
  }
1490
2434
  logSessionStart(`lsp spawn ${server.id}: aborted (service shut down mid-spawn)`);
2435
+ this.noteSpawnVerdict(key, "declined");
1491
2436
  return undefined;
1492
2437
  }
1493
2438
  if (!spawned) {
@@ -1501,6 +2446,17 @@ export class LSPService {
1501
2446
  if (!allowInstall) {
1502
2447
  logSessionStart(`lsp spawn ${server.id}: unavailable with install disabled; temporary cooldown only`);
1503
2448
  this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
2449
+ // #1934 review F1: this branch sets a cooldown but is a POLICY
2450
+ // decline by the comment above — the binary may appear on PATH
2451
+ // later in the same session, so it never counts toward permanent
2452
+ // disablement and the cooldown has no ladder. With installs
2453
+ // disabled or a project untrusted, a missing binary reaches here
2454
+ // once per 15s per (server, root) for the whole session. Calling
2455
+ // that a spawn failure would write thousands of mislabeled
2456
+ // records a day, so it reads as a decline. The event is already
2457
+ // recorded once by `lsp_client_unavailable` and by the
2458
+ // `sessionstart.log` line above.
2459
+ this.noteSpawnVerdict(key, "declined");
1504
2460
  return undefined;
1505
2461
  }
1506
2462
  const uCount = (this.failureCounts.get(key) ?? 0) + 1;
@@ -1512,6 +2468,9 @@ export class LSPService {
1512
2468
  this.recordBreaker(key, `permanently disabled after ${uCount} unavailable spawns`);
1513
2469
  logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${uCount} failures`);
1514
2470
  }
2471
+ // Installs were allowed and the server is still unavailable: a real
2472
+ // failure, on the exponential ladder toward permanent disablement.
2473
+ this.noteSpawnVerdict(key, "failed");
1515
2474
  return undefined;
1516
2475
  }
1517
2476
  const override = getServerInitOverride(server.id, filePath);
@@ -1529,6 +2488,7 @@ export class LSPService {
1529
2488
  if (this.isDestroyed) {
1530
2489
  client.shutdown({ fast: true }).catch(() => { });
1531
2490
  logSessionStart(`lsp spawn ${server.id}: aborted (service shut down mid-initialize)`);
2491
+ this.noteSpawnVerdict(key, "declined");
1532
2492
  return undefined;
1533
2493
  }
1534
2494
  const wsDiag = typeof client.getWorkspaceDiagnosticsSupport === "function"
@@ -1540,6 +2500,9 @@ export class LSPService {
1540
2500
  };
1541
2501
  this.state.clients.set(key, client);
1542
2502
  this.unavailableLogged.delete(key);
2503
+ // #1934 review F1: a success retires the previous verdict, so the map
2504
+ // never outlives the attempts it describes.
2505
+ this.lastSpawnVerdict.delete(key);
1543
2506
  this.state.clientSpawnedAt.set(key, Date.now());
1544
2507
  this.clientLastUsedAt.set(key, Date.now());
1545
2508
  this.scheduleTypeScriptIdleEviction(key);
@@ -1549,7 +2512,9 @@ export class LSPService {
1549
2512
  this.optionalFailureLogged.delete(key);
1550
2513
  }
1551
2514
  logSessionStart(`lsp spawn ${server.id}: success source=${spawned.source ?? "unknown"} (${Date.now() - startedAt}ms)`);
1552
- recordLsp(server.id, root, "spawn_success", Date.now() - startedAt);
2515
+ const spawnDurationMs = Date.now() - startedAt;
2516
+ recordLsp(server.id, root, "spawn_success", spawnDurationMs);
2517
+ recordSuccessfulLspSpawn(server.id, spawnDurationMs);
1553
2518
  if (!this.workspaceProbeLogged.has(key)) {
1554
2519
  logSessionStart(`lsp workspace-diag probe ${server.id}: advertised=${wsDiag.advertised} mode=${wsDiag.mode} provider=${wsDiag.diagnosticProviderKind}`);
1555
2520
  this.workspaceProbeLogged.add(key);
@@ -1578,6 +2543,8 @@ export class LSPService {
1578
2543
  if (isOptionalServer) {
1579
2544
  this.optionalDisabled.add(key);
1580
2545
  }
2546
+ // The spawn or the initialize handshake threw: a server failure.
2547
+ this.noteSpawnVerdict(key, "failed");
1581
2548
  return undefined;
1582
2549
  }
1583
2550
  }
@@ -1587,9 +2554,19 @@ export class LSPService {
1587
2554
  async openFile(filePath, content, options) {
1588
2555
  if (this.checkDestroyed())
1589
2556
  return;
2557
+ // #1783: anchored before the client acquisition, for the same reason
2558
+ // touchFile anchors on its own start — see recordFullyCoveredSync.
2559
+ const startedAt = Date.now();
1590
2560
  await this.withClientForFileUse(filePath, undefined, options?.spawnBudgetMs, async (spawned) => {
1591
2561
  const languageId = getLanguageId(filePath) ?? "plaintext";
1592
2562
  await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
2563
+ // #1783: openFile is a real sync path — actionable-warnings and the
2564
+ // diagnostic-freshness callers reach a server through here and never
2565
+ // through touchFile. Without this, those documents were invisible to
2566
+ // the drift backstop. The same full-coverage gate applies, so an
2567
+ // auxiliary holding the document keeps the record unwritten rather
2568
+ // than letting one client's push claim every view is current.
2569
+ this.recordFullyCoveredSync(filePath, content, [spawned], true, startedAt);
1593
2570
  });
1594
2571
  }
1595
2572
  /**
@@ -1605,20 +2582,58 @@ export class LSPService {
1605
2582
  * and optionally collect diagnostics with explicit scope.
1606
2583
  */
1607
2584
  async touchFile(filePath, content, options = {}) {
1608
- if (this.checkDestroyed())
2585
+ if (this.checkDestroyed()) {
2586
+ // #1618: a destroyed service never reaches the language server — this
2587
+ // early return used to log nothing at all, so a workspace sweep whose
2588
+ // idle-reset timer fired mid-run left every remaining file with zero
2589
+ // trace, indistinguishable from budget exhaustion. Cheap and local: no
2590
+ // server round trip, matching the `no_clients`/`success` sibling
2591
+ // records this phase already emits below.
2592
+ const destroyedDiagnosticsMode = options.collectDiagnostics
2593
+ ? (options.diagnostics ?? "document")
2594
+ : (options.diagnostics ?? "none");
2595
+ logLatency({
2596
+ type: "phase",
2597
+ phase: "lsp_touch_file",
2598
+ filePath: normalizeMapKey(filePath),
2599
+ durationMs: 0,
2600
+ metadata: {
2601
+ serverCountAttempted: 0,
2602
+ serverCountReady: 0,
2603
+ clientScope: options.clientScope ??
2604
+ (destroyedDiagnosticsMode === "full" ? "all" : "primary"),
2605
+ diagnosticsMode: destroyedDiagnosticsMode,
2606
+ source: options.source ?? "unknown",
2607
+ failureKind: "destroyed",
2608
+ },
2609
+ });
1609
2610
  return;
2611
+ }
1610
2612
  const startedAt = Date.now();
1611
2613
  const normalizedPath = normalizeMapKey(filePath);
2614
+ // #1783: every path that asks a language server anything comes through
2615
+ // here, so this is where the disk-drift backstop gets its heartbeat.
2616
+ // Deliberately NOT awaited: the sweep is rate-limited to one pass per
2617
+ // 10s and runs alongside this touch's own client acquisition and
2618
+ // diagnostics wait, so it adds nothing to this call's latency. The
2619
+ // resync it may issue re-enters touchFile; every such re-entry happens
2620
+ // while the pass is still running, so the tracker's single-flight guard
2621
+ // returns the running pass and no recursion occurs. That guard is the
2622
+ // single mechanism — there is deliberately no second source-based check
2623
+ // here, which would be an unprovable duplicate of it.
2624
+ void this.sweepDocumentDrift().catch(() => { });
1612
2625
  const diagnosticsMode = options.collectDiagnostics
1613
2626
  ? (options.diagnostics ?? "document")
1614
2627
  : (options.diagnostics ?? "none");
1615
2628
  const source = options.source ?? "unknown";
1616
2629
  const clientScope = options.clientScope ?? (diagnosticsMode === "full" ? "all" : "primary");
1617
2630
  const useAllClients = clientScope === "all";
2631
+ const resolvedPrimaryRoots = new Map();
2632
+ const waitSkipReasons = new Set();
1618
2633
  let spawned;
1619
2634
  let serverCountAttempted;
1620
2635
  if (useAllClients) {
1621
- const result = await this.getClientsForFile(filePath, options.excludeServerIds);
2636
+ const result = await this.getClientsForFile(filePath, options.excludeServerIds, resolvedPrimaryRoots);
1622
2637
  spawned = result.clients;
1623
2638
  serverCountAttempted = result.serverCountAttempted;
1624
2639
  }
@@ -1626,14 +2641,14 @@ export class LSPService {
1626
2641
  // Primary language server + the enabled cross-cutting auxiliaries
1627
2642
  // (opengrep, …). The aggregation layer merges/dedups their diagnostics.
1628
2643
  const [entry, aux] = await Promise.all([
1629
- this.getClientForFile(filePath, options.maxClientWaitMs),
2644
+ this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons),
1630
2645
  this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? [])),
1631
2646
  ]);
1632
2647
  spawned = entry ? [entry, ...aux] : aux;
1633
2648
  serverCountAttempted = spawned.length;
1634
2649
  }
1635
2650
  else {
1636
- const entry = await this.getClientForFile(filePath, options.maxClientWaitMs);
2651
+ const entry = await this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons);
1637
2652
  spawned = entry ? [entry] : [];
1638
2653
  serverCountAttempted =
1639
2654
  spawned.length > 0
@@ -1643,6 +2658,14 @@ export class LSPService {
1643
2658
  : 0;
1644
2659
  }
1645
2660
  if (spawned.length === 0) {
2661
+ // A bounded caller can lose the client race while the single-flight spawn
2662
+ // it started is still progressing. Preserve that lifecycle evidence in the
2663
+ // touch verdict instead of reclassifying an empty ready set as absence.
2664
+ // `isSpawnInFlight` reads the spawn coordinator's own state and filters to
2665
+ // primary candidates, so this stays coupled to the dedupe mechanism.
2666
+ const failureKind = this.isSpawnInFlight(filePath, resolvedPrimaryRoots)
2667
+ ? "spawn_in_flight_budget_elapsed"
2668
+ : "no_clients_none_spawning";
1646
2669
  logLatency({
1647
2670
  type: "phase",
1648
2671
  phase: "lsp_touch_file",
@@ -1655,7 +2678,10 @@ export class LSPService {
1655
2678
  diagnosticsMode,
1656
2679
  source,
1657
2680
  maxClientWaitMs: options.maxClientWaitMs,
1658
- failureKind: "no_clients",
2681
+ failureKind,
2682
+ ...(waitSkipReasons.size > 0
2683
+ ? { reason: [...waitSkipReasons][0] }
2684
+ : {}),
1659
2685
  },
1660
2686
  });
1661
2687
  return;
@@ -1703,7 +2729,22 @@ export class LSPService {
1703
2729
  // no-new-version baseline below.
1704
2730
  const notifySkippedServerIds = new Set(spawnedServerIds.filter((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId)));
1705
2731
  const notifySkipped = spawned.length > 0 && notifySkippedServerIds.size === spawned.length;
1706
- const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
2732
+ // #1531: the pre-notify diagnostics baseline for THIS file on each client.
2733
+ // It used to be `client.diagnosticsVersion`, a client-GLOBAL counter that also
2734
+ // advances for files this touch never mentions — which let a sibling file's
2735
+ // publication both end this file's wait early and read as an answer for it.
2736
+ // `getDiagnosticsVersionForPath` returns that same counter's value as of this
2737
+ // file's last publication, so every comparison downstream stays on one axis
2738
+ // while ignoring sibling paths. Captured here because the notify below clears
2739
+ // each client's cache for the file.
2740
+ //
2741
+ // The accessor is REQUIRED on `LSPClient`, so a real client always answers
2742
+ // with a number. The optional call is only so a hand-written test double that
2743
+ // predates it fails CLOSED — `undefined` keeps the existing "no usable
2744
+ // baseline" branch below and can never satisfy the evidence check — instead of
2745
+ // quietly reverting to the global counter, which is the defect itself.
2746
+ const readPathVersion = (client) => client.getDiagnosticsVersionForPath?.(filePath);
2747
+ const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, readPathVersion(entry.client)]));
1707
2748
  // #1458: read a late auxiliary publication BEFORE the ordinary resync
1708
2749
  // clears its client cache. Carry it only when the publication's exact
1709
2750
  // sent-content fingerprint matches this touch's content. A changed edit,
@@ -1711,17 +2752,77 @@ export class LSPService {
1711
2752
  // replayed. The fresh notify still runs below, so scanners continue toward
1712
2753
  // a publication for this touch while the prior late result reaches the read.
1713
2754
  const touchContentHash = this.hashContent(content);
2755
+ // #1586: THE content-match atom. Every content-bound question in this touch
2756
+ // — the carry-over below, #1493's pre-notify snapshot, and the merge-time
2757
+ // coverage predicate — asks it here and nowhere else, so a door cannot
2758
+ // acquire a rule of its own by writing the comparison inline. A binding with
2759
+ // no `contentHash` (version-less publish) fails closed: `undefined` never
2760
+ // equals a hash.
2761
+ const bindingMatchesTouchContent = (binding) => binding?.contentHash === touchContentHash;
1714
2762
  const carriedAuxiliary = options.collectDiagnostics
1715
2763
  ? spawned.flatMap((entry) => {
1716
2764
  if (entry.info.role !== "auxiliary")
1717
2765
  return [];
1718
2766
  const binding = entry.client.getDiagnosticBinding?.(filePath);
1719
- if (binding?.contentHash !== touchContentHash)
2767
+ if (!bindingMatchesTouchContent(binding))
1720
2768
  return [];
1721
2769
  const diags = entry.client.getDiagnostics(filePath);
1722
2770
  return diags.length > 0 ? [{ diags, binding }] : [];
1723
2771
  })
1724
2772
  : [];
2773
+ // #1493: auxiliaries whose STORED publication already covers exactly the
2774
+ // bytes this touch carries. Read BEFORE the notify below, which clears each
2775
+ // client's cache for the file. Unlike `carriedAuxiliary` this does not
2776
+ // require findings: an empty publication bound to this content is evidence
2777
+ // the scanner reported, which is what keeps a genuinely clean file clean
2778
+ // when its wait produces nothing new (a debounce-skipped notify, or a late
2779
+ // publication carried in from the previous touch).
2780
+ const auxPublishedThisContent = new Set(spawned.flatMap((entry) => entry.info.role === "auxiliary" &&
2781
+ bindingMatchesTouchContent(entry.client.getDiagnosticBinding?.(filePath))
2782
+ ? [entry.info.id]
2783
+ : []));
2784
+ const spawnedByServerId = new Map(spawned.map((entry) => [entry.info.id, entry]));
2785
+ // #1549/#1586: does this auxiliary's publication describe exactly the bytes
2786
+ // this touch carries? THE coverage predicate — every door reads it, so a
2787
+ // scanner can never be named uncovered while its findings ride along in
2788
+ // `.diags`, or the reverse.
2789
+ //
2790
+ // It UNIONS two content-bound reads rather than replacing one with the other,
2791
+ // because they answer different questions:
2792
+ //
2793
+ // - `auxPublishedThisContent` was captured BEFORE the notify (#1493), because
2794
+ // a landed write clears the cache and would erase the evidence that the
2795
+ // scanner had already reported on these bytes.
2796
+ // - the read below is LIVE, and it catches the opposite race — #1459's own
2797
+ // documented signature: a write charged as timed out, or one the fan-out
2798
+ // gate deferred behind, that LANDS LATE, after which the scanner publishes
2799
+ // for this touch's content. Judging that auxiliary on the pre-notify
2800
+ // snapshot alone drops its CURRENT findings and names it uncovered — an
2801
+ // underclaim about a scanner that answered.
2802
+ //
2803
+ // Either match means covered; both are content-bound, so neither can pass off
2804
+ // another revision's findings as this touch's answer.
2805
+ //
2806
+ // WHEN it is asked is part of the rule. The live half moves over the life of
2807
+ // a touch, so two doors that ask at two instants can disagree — and the merge
2808
+ // ACTS on its answer by dropping findings, which a later answer cannot undo.
2809
+ // Every door that shares the merge's consequences therefore reads ONE frozen
2810
+ // evaluation (`auxCoveredAtMerge`, below), taken immediately before the merge
2811
+ // and never re-asked afterwards. The only callers of this function are that
2812
+ // freeze and the two aux wait-outcome producers, whose rows describe their own
2813
+ // instant and are reconciled against the freeze before anything is claimed.
2814
+ //
2815
+ // Everything it cannot speak for fails CLOSED — an id that never reached
2816
+ // `spawned` (a breaker-skipped scanner, which never attached) and any
2817
+ // primary-role server, whose findings are governed by #570's
2818
+ // timeout-preserves-last-known semantics rather than by this exemption.
2819
+ const auxCoversThisContent = (serverId) => {
2820
+ const entry = spawnedByServerId.get(serverId);
2821
+ if (entry?.info.role !== "auxiliary")
2822
+ return false;
2823
+ return (auxPublishedThisContent.has(serverId) ||
2824
+ bindingMatchesTouchContent(entry.client.getDiagnosticBinding?.(filePath)));
2825
+ };
1725
2826
  // #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
1726
2827
  // write gets its OWN notifyWriteBudgetMs budget rather than one shared
1727
2828
  // deadline over a single Promise.all — otherwise one backpressured server
@@ -1736,8 +2837,22 @@ export class LSPService {
1736
2837
  // server timed out"; this list carries the per-server detail the
1737
2838
  // demonstratedReady gate reads so a healthy sibling stays eligible.
1738
2839
  const notifyWriteTimedOutServerIds = [];
2840
+ // #1459: auxiliaries whose resync was DEFERRED because the gate already had
2841
+ // one outstanding write for that server. They carry no evidence about this
2842
+ // content, so they join the coverage gap below.
2843
+ const notifyDeferredServerIds = [];
1739
2844
  if (!notifySkipped) {
1740
2845
  const budget = notifyWriteBudgetMs();
2846
+ // #1459: how long a queued auxiliary may wait for its resync slot. Bounded
2847
+ // by the write budget AND by whatever the caller already declared it is
2848
+ // willing to spend on this touch (`maxClientWaitMs` — cascade's cold
2849
+ // snapshot passes 1000ms), minus what the client wait above already spent.
2850
+ // A flat write-budget wait would tax a caller that asked for less than one
2851
+ // budget in total. Non-positive means "no time left to queue": the server
2852
+ // is reported as uncovered immediately.
2853
+ const queueWaitMs = options.maxClientWaitMs !== undefined
2854
+ ? Math.min(budget, Math.max(0, options.maxClientWaitMs - (Date.now() - startedAt)))
2855
+ : budget;
1741
2856
  await Promise.all(spawned.map(async (entry) => {
1742
2857
  // #743: this server already has this content from a recent touch
1743
2858
  // that landed. Pushing again would clear its diagnostic cache for
@@ -1747,12 +2862,95 @@ export class LSPService {
1747
2862
  return;
1748
2863
  // Same identity as the broken/demonstratedReady maps.
1749
2864
  const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
2865
+ // #1459: one outstanding resync per auxiliary. Primaries are
2866
+ // untouched — they serve one file per touch and are not the fan-out
2867
+ // target a `clientScope: "all"` sweep floods.
2868
+ const gated = entry.info.role === "auxiliary" && clientKey !== undefined;
2869
+ let slot;
2870
+ if (gated && clientKey) {
2871
+ // #1714: before taking the slot, make the server prove it
2872
+ // processed the notifies already sent. A sweep is sequential, so
2873
+ // the slot gate below is almost always free and cannot see a
2874
+ // backlog building. This never refuses the write — a scanner that
2875
+ // will not answer is latched past and left to #743's stall
2876
+ // machinery, which can demote and respawn it.
2877
+ const barrierStartedAt = Date.now();
2878
+ await this.paceAuxNotify(clientKey, entry, filePath, queueWaitMs, {
2879
+ source,
2880
+ clientScope,
2881
+ });
2882
+ // The barrier spends from the SAME budget the caller granted, so
2883
+ // the slot wait gets only what is left. Otherwise a paced touch
2884
+ // could cost two full budgets.
2885
+ const slotWaitMs = Math.max(0, queueWaitMs - (Date.now() - barrierStartedAt));
2886
+ const claim = await this.claimAuxNotifySlot(clientKey, entry, filePath, slotWaitMs);
2887
+ if ("outstandingMs" in claim) {
2888
+ // Queued behind a write the scanner has not accepted inside our
2889
+ // budget. Pushing anyway is what floods it, so this touch reports
2890
+ // the scanner as uncovered instead. The wedge timer armed with the
2891
+ // blocking write is what demotes a dead input path.
2892
+ notifyDeferredServerIds.push(entry.info.id);
2893
+ logLatency({
2894
+ type: "phase",
2895
+ phase: "lsp_notify_resync_deferred",
2896
+ filePath: normalizedPath,
2897
+ durationMs: claim.outstandingMs,
2898
+ metadata: {
2899
+ serverId: entry.info.id,
2900
+ source,
2901
+ clientScope,
2902
+ reason: "outstanding_write",
2903
+ outstandingMs: claim.outstandingMs,
2904
+ queueWaitMs,
2905
+ },
2906
+ });
2907
+ return;
2908
+ }
2909
+ slot = claim;
2910
+ }
1750
2911
  let wrote;
1751
2912
  let rejected = false;
1752
2913
  try {
1753
- wrote = await withDeadline(entry.client.notify
2914
+ const writeStartedAt = Date.now();
2915
+ // Constructed inside the try so a client double without `notify`
2916
+ // (or any synchronous throw) still reads as a rejected write rather
2917
+ // than rejecting the whole per-file `Promise.all`.
2918
+ const writePromise = entry.client.notify
1754
2919
  .open(filePath, content, languageId, undefined, silent)
1755
- .then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
2920
+ .then(() => true);
2921
+ // #1714: the document is now in this auxiliary's input queue,
2922
+ // whether or not the write settles inside our budget. Counted here
2923
+ // so the next file sees the real backlog.
2924
+ if (gated && clientKey) {
2925
+ this.noteAuxNotifyIssued(clientKey, entry.client);
2926
+ }
2927
+ if (slot && clientKey) {
2928
+ const client = entry.client;
2929
+ const release = slot.release;
2930
+ // Release the slot on the write's OWN settle, whatever the caller
2931
+ // below decided to wait for. The handle is identity-checked, so a
2932
+ // demotion (which clears the map) or a later claim cannot be
2933
+ // released by this one.
2934
+ void writePromise.then(() => {
2935
+ release();
2936
+ // The write landed, just not inside the caller's budget —
2937
+ // retract the timeout it was charged for. A write that landed
2938
+ // IN budget took the success path below, which clears the
2939
+ // streak outright, so only the late case retracts. A landing
2940
+ // past the WEDGE window keeps its strike: at that point the
2941
+ // stall was long enough that #743's demotion is the honest
2942
+ // verdict, not a latency artifact.
2943
+ const outstandingMs = Date.now() - writeStartedAt;
2944
+ if (outstandingMs > budget && outstandingMs <= notifyWedgedMs()) {
2945
+ this.retractNotifyWriteBackpressure(clientKey, entry.info.id, filePath, outstandingMs, client);
2946
+ }
2947
+ }, release);
2948
+ }
2949
+ wrote = await withDeadline(writePromise, {
2950
+ ms: budget,
2951
+ onTimeout: "undefined",
2952
+ onReject: "propagate",
2953
+ });
1756
2954
  }
1757
2955
  catch {
1758
2956
  // The write itself rejected (not backpressure): the content did
@@ -1760,6 +2958,10 @@ export class LSPService {
1760
2958
  // rejection is not a stdin-backpressure signal and must not count
1761
2959
  // toward the backpressure demotion streak.
1762
2960
  rejected = true;
2961
+ // A synchronous throw (a client double without `notify`) never
2962
+ // reached the settle handlers that release the slot — release it
2963
+ // here so one bad client cannot wedge the queue. Idempotent.
2964
+ slot?.release();
1763
2965
  }
1764
2966
  if (wrote === true) {
1765
2967
  // A clean write clears any accrued backpressure streak (#743).
@@ -1780,6 +2982,25 @@ export class LSPService {
1780
2982
  }
1781
2983
  }
1782
2984
  }));
2985
+ // #1783: stamp the disk-drift record only when the touch achieved FULL
2986
+ // coverage — every targeted server's write landed AND no other live
2987
+ // client holds this document. The debounce entry above is per-server, so
2988
+ // stamping a per-FILE record inside that loop claimed a coverage the
2989
+ // touch may not have had: a primary-scoped touch leaves an auxiliary's
2990
+ // view untouched, and a touch where one server times out leaves that
2991
+ // server behind. Either way the sweep would then read "in sync" and stop
2992
+ // looking. On a partial touch the PREVIOUS record is deliberately kept:
2993
+ // its older `syncedAt` and older fingerprint keep the document eligible,
2994
+ // so the next sweep re-pushes it at full scope instead of going blind.
2995
+ //
2996
+ // BOTH exit lists, not just the timed-out one. The #1459 gate defers an
2997
+ // auxiliary whose previous write is still outstanding, and that server
2998
+ // leaves the write loop early without ever joining
2999
+ // `notifyWriteTimedOutServerIds`. Reading only that list let a deferred
3000
+ // scanner's untouched view be stamped as covered — the same laundering
3001
+ // through a different door.
3002
+ this.recordFullyCoveredSync(filePath, content, spawned, notifyWriteTimedOutServerIds.length === 0 &&
3003
+ notifyDeferredServerIds.length === 0, startedAt);
1783
3004
  if (notifyWriteTimedOutServerIds.length > 0) {
1784
3005
  logLatency({
1785
3006
  type: "phase",
@@ -1795,14 +3016,50 @@ export class LSPService {
1795
3016
  });
1796
3017
  }
1797
3018
  }
1798
- // File-level flag: at least one server's write timed out (kept for the
1799
- // conservative touch-wide `inconclusive` merge semantics — see below).
3019
+ // File-level flag: at least one server's write timed out. Kept as the
3020
+ // observability summary (`lsp_touch_file.notifyWriteTimedOut`); the
3021
+ // `inconclusive` verdict reads the PRIMARY-scoped flag below (#1549).
1800
3022
  const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
3023
+ // #1549: the honesty verdict is decided from the PRIMARY population only.
3024
+ // An auxiliary that missed a deadline is a named coverage gap, never an
3025
+ // inconclusive touch — see `resolveTouchVerdict` (diagnostic-binding.ts).
3026
+ const primaryEntries = spawned.filter((entry) => entry.info.role !== "auxiliary");
3027
+ const primaryServerIds = new Set(primaryEntries.map((e) => e.info.id));
3028
+ const primaryNotifyWriteTimedOutServerIds = notifyWriteTimedOutServerIds.filter((id) => primaryServerIds.has(id));
3029
+ const primaryNotifyWriteTimedOut = primaryNotifyWriteTimedOutServerIds.length > 0;
3030
+ // #1459: read by the diagnostics wait and the merge below — a deferred
3031
+ // server is neither waited on nor read from.
3032
+ const deferredResyncServerIds = new Set(notifyDeferredServerIds);
1801
3033
  let diagnosticsTimedOut = false;
3034
+ // #1549: every server (any role) that produced no publication evidence when
3035
+ // the diagnostics wait lapsed. Read three ways: the primary members decide
3036
+ // `diagnosticsTimedOut` and are the `inconclusiveServerIds` attribution, the
3037
+ // auxiliary members join the coverage gap, and no member may be marked
3038
+ // `demonstratedReady`. Empty when the wait did not lapse.
3039
+ let diagnosticsUnansweredServerIds = [];
3040
+ // #1549: the primary subset of the list above, captured when the wait lapsed
3041
+ // so the attribution survives the silent-clean/sync gates clearing the flag.
3042
+ let diagnosticsUnansweredPrimaryServerIds = [];
3043
+ // #1549: a gate that certifies silence AS the answer (the tsserver sync
3044
+ // confirm, either silent-clean gate) retracts the primary attribution with it —
3045
+ // those servers answered, in the only way their capabilities allow, so they
3046
+ // stay eligible for `demonstratedReady` exactly as before this change.
3047
+ // Auxiliary members are left in place: nothing certified them, and they are
3048
+ // what the coverage gap reports.
3049
+ const retractPrimaryTimeoutAttribution = () => {
3050
+ diagnosticsUnansweredPrimaryServerIds = [];
3051
+ diagnosticsUnansweredServerIds = diagnosticsUnansweredServerIds.filter((id) => !primaryServerIds.has(id));
3052
+ };
1802
3053
  // R8 (#714): server ids of aux-role servers whose push wait was cut off by
1803
3054
  // the aux grace window. Undefined when no aux was cut off (primary-only
1804
3055
  // paths never set this). Logged in lsp_touch_file metadata.
1805
3056
  let auxCutOffServerIds;
3057
+ // #1493: aux-role servers this touch carries NO evidence from — the cut-off
3058
+ // set above PLUS the ones that stayed silent through their own budget with
3059
+ // no stored publication for this content. This is what narrows the
3060
+ // confirmation; `auxCutOffServerIds` stays cut_off-only so the R8 latency
3061
+ // field keeps its original meaning.
3062
+ let auxUnconfirmedServerIds;
1806
3063
  // #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
1807
3064
  // full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
1808
3065
  // holds the sync commands' answer when the racing confirm won the wait
@@ -1957,7 +3214,13 @@ export class LSPService {
1957
3214
  timeoutFor = () => callerCap ?? modeFloor;
1958
3215
  }
1959
3216
  // Detection deadline = the slowest individual server's budget.
1960
- const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
3217
+ // #1459: computed over the servers actually WAITED ON. A deferred server
3218
+ // contributes no wait, so including its (typically longest) scanner budget
3219
+ // here would raise the aggregate threshold above anything that can elapse
3220
+ // and mask a real timeout on the servers that did wait.
3221
+ const timeoutMs = Math.max(0, ...spawned
3222
+ .filter((e) => !deferredResyncServerIds.has(e.info.id))
3223
+ .map((e) => timeoutFor(e.client.serverId)));
1961
3224
  // #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
1962
3225
  // starts. Cheap synchronous gates first (notify succeeded, collecting,
1963
3226
  // primary scope, `serverId === "typescript"` — the sync commands this
@@ -2027,21 +3290,51 @@ export class LSPService {
2027
3290
  }
2028
3291
  }
2029
3292
  const perServerWaits = spawned.map((entry) => {
3293
+ // #1459: a DEFERRED server never received this content, so its version
3294
+ // can never advance past the baseline — waiting on it burns its whole
3295
+ // budget and would flip the touch to `inconclusive`, discarding a
3296
+ // primary answer that IS trustworthy. It contributes no wait; the
3297
+ // coverage gap below is what reports its absence.
3298
+ if (deferredResyncServerIds.has(entry.info.id)) {
3299
+ return Promise.resolve(undefined);
3300
+ }
2030
3301
  const serverTimeout = timeoutFor(entry.client.serverId);
3302
+ // #1531: a per-path baseline. `clientWaitForDiagnostics` compares it
3303
+ // against this path's own publication stamp, so a sibling file's
3304
+ // publication on a shared client can no longer end this wait before the
3305
+ // server's own budget lapses — which is what kept the outcome labels
3306
+ // honest (`cut_off` means our grace won, `silent` means the server's own
3307
+ // budget lapsed with nothing published).
2031
3308
  const baseline = diagnosticBaselines.get(entry.client);
2032
3309
  const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
3310
+ // #1639: `ensureWarmForSweep`'s readiness probe (`source:
3311
+ // "lsp_sweep_warmup"`, `collectDiagnostics: false`) runs a real pull
3312
+ // round trip on this same file, then the sweep's real touch follows
3313
+ // immediately after — two legitimate settle observations for one
3314
+ // file, not a duplicate. Tag the warm-up one distinctly so a
3315
+ // consumer can tell them apart instead of double-counting. Omitted
3316
+ // (rather than passed as "pull") on the common path — the client
3317
+ // already defaults to "pull", and existing tests assert the exact
3318
+ // argument list `waitForDiagnostics` is called with.
3319
+ const isWarmupTouch = source === "lsp_sweep_warmup";
2033
3320
  // #743: per-server — a server we DID push to still gets the
2034
3321
  // version-baseline wait even when a sibling was debounced away.
2035
3322
  const wait = !notifySkippedServerIds.has(entry.info.id) && Number.isFinite(baseline)
2036
3323
  ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
2037
3324
  minVersion: baseline,
2038
3325
  ...(pullOnly && { pullOnly: true }),
3326
+ ...(isWarmupTouch && { pullSettleSource: "pull-warmup" }),
2039
3327
  })
2040
3328
  : pullOnly
2041
3329
  ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
2042
3330
  pullOnly: true,
3331
+ ...(isWarmupTouch && { pullSettleSource: "pull-warmup" }),
2043
3332
  })
2044
- : entry.client.waitForDiagnostics(filePath, serverTimeout);
3333
+ : isWarmupTouch
3334
+ ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
3335
+ pullSettleSource: "pull-warmup",
3336
+ })
3337
+ : entry.client.waitForDiagnostics(filePath, serverTimeout);
2045
3338
  return wait.catch(() => undefined);
2046
3339
  });
2047
3340
  // The push wait — same per-server budget composition as before #707;
@@ -2112,17 +3405,43 @@ export class LSPService {
2112
3405
  // NOT the same as having answered).
2113
3406
  // - raced === true, evidence → "answered" (a fresh
2114
3407
  // publication actually landed for this touch).
3408
+ //
3409
+ // #1531: the evidence is read PER PATH. The global
3410
+ // `diagnosticsVersion` advances for every file this client
3411
+ // publishes, so a concurrent touch of an unrelated file used to
3412
+ // hand this one an unearned "answered" row. The per-path stamp
3413
+ // carries the global counter's value at store time, so the
3414
+ // comparison stays monotonic across cache evictions while
3415
+ // ignoring sibling paths — and it is the SAME axis `baseline`
3416
+ // was captured on above.
3417
+ const currentPathVersion = readPathVersion(aux.client);
2115
3418
  const publishedEvidence = raced &&
2116
3419
  Number.isFinite(aux.baseline) &&
2117
- aux.client.diagnosticsVersion > aux.baseline;
2118
- const outcome = !raced
2119
- ? "cut_off"
2120
- : publishedEvidence
2121
- ? "answered"
2122
- : "silent";
3420
+ currentPathVersion !== undefined &&
3421
+ currentPathVersion > aux.baseline;
3422
+ // #1459: a DEFERRED aux was never sent this content and is not
3423
+ // waited on at all, so its instantly-resolved placeholder
3424
+ // promise must not read as "silent". "Silent" is the reserved
3425
+ // signal for a scanner that HAD the content, finished inside
3426
+ // its own budget, and published nothing (#1493) — recording a
3427
+ // deferral there would corrupt the one row that tracks it.
3428
+ const outcome = deferredResyncServerIds.has(aux.serverId)
3429
+ ? "deferred"
3430
+ : !raced
3431
+ ? "cut_off"
3432
+ : publishedEvidence
3433
+ ? "answered"
3434
+ : "silent";
2123
3435
  return {
2124
3436
  serverId: aux.serverId,
2125
3437
  outcome,
3438
+ // #1493: carried into the coverage-gap policy so a silent
3439
+ // auxiliary that already published for these exact bytes is
3440
+ // not demoted. Logged too — it is the reason a `silent` row
3441
+ // did not narrow the touch.
3442
+ // #1586: through the one predicate, so this row and the merge
3443
+ // below cannot disagree about the same scanner.
3444
+ publishedThisContent: auxCoversThisContent(aux.serverId),
2126
3445
  budgetMs,
2127
3446
  elapsedMs: Date.now() - auxWaitStartedAt,
2128
3447
  // #1458 S3: elapsed measured from BEFORE the primary wait
@@ -2138,12 +3457,23 @@ export class LSPService {
2138
3457
  .map((outcome) => outcome.serverId);
2139
3458
  if (unfinished.length > 0)
2140
3459
  auxCutOffServerIds = unfinished;
3460
+ // #1493: one policy over both no-answer shapes. Lives in
3461
+ // diagnostic-binding.ts so no consumer re-derives the rule from
3462
+ // an outcome string.
3463
+ const uncovered = auxiliaryCoverageGap(outcomes);
3464
+ if (uncovered.length > 0)
3465
+ auxUnconfirmedServerIds = uncovered;
2141
3466
  logLatency({
2142
3467
  type: "phase",
2143
3468
  phase: "lsp_aux_wait_outcome",
2144
3469
  filePath: normalizedPath,
2145
3470
  durationMs: Date.now() - auxWaitStartedAt,
2146
- metadata: { clientScope, outcomes },
3471
+ // #1533: `waitShape` names the producer, because the aggregate
3472
+ // path emits the same row with the same outcome vocabulary
3473
+ // minus `cut_off`. A field query that sees only `silent` rows
3474
+ // must be able to tell "our ceiling was in play" from "the
3475
+ // auxiliary's own full budget lapsed".
3476
+ metadata: { clientScope, waitShape: "aux_grace", outcomes },
2147
3477
  });
2148
3478
  });
2149
3479
  })()
@@ -2219,6 +3549,109 @@ export class LSPService {
2219
3549
  await pushWait;
2220
3550
  }
2221
3551
  const waitedMs = Date.now() - waitStartedAt;
3552
+ // #1533: the same auxiliary coverage evidence for a collecting touch that
3553
+ // did NOT enter the aux-grace wait — in practice `clientScope: "all"`, the
3554
+ // batch/directory scan surface. Auxiliaries ARE spawned on that scope
3555
+ // (`getClientsForFile` returns every matching server, #573) and each one is
3556
+ // waited on inside `Promise.all(perServerWaits)` on its own per-server
3557
+ // budget, but `hasTouchAuxiliaries` is `with-auxiliary`-only, so no evidence
3558
+ // was ever derived and a silent scanner aggregated as an unqualified
3559
+ // `"confirmed"` — the #1493 false clean surviving on a different scope.
3560
+ //
3561
+ // NO SECOND WAIT. Every aux promise here has already settled (the
3562
+ // `Promise.all` above awaited it), so this reads post-wait state only. That
3563
+ // is deliberate: #1459's resync gate exists to ABSORB the aux fan-out of an
3564
+ // "all"-scope sweep into deferrals, and entering a per-neighbour aux grace
3565
+ // here would pay back the latency that gate just recovered. The evidence is
3566
+ // free; only the verdict changes.
3567
+ //
3568
+ // WHICH verdicts change, stated without overreach. Where the auxiliary's
3569
+ // budget is the MAX over waited servers (`perServerTimeout` is
3570
+ // `min(callerCap, strategyWait)` per server, `timeoutMs` is the max across
3571
+ // them), a silent auxiliary already tripped `diagnosticsTimedOut` and the
3572
+ // touch was already `inconclusive` — which is decided BEFORE the coverage
3573
+ // gap, so those results are unchanged. That covers opengrep on every current
3574
+ // per-edit path, whose 3500 exceeds either cap. But a FASTER auxiliary beside
3575
+ // a slower primary (typos 1500 or ast-grep 1800 next to rust-analyzer 3000
3576
+ // under a 2000 cap) settles inside `timeoutMs`, so nothing timed out and this
3577
+ // block genuinely narrows a result that used to read `confirmed`. That is the
3578
+ // fix working: the scanner said nothing about these bytes. It is fail-safe —
3579
+ // the primary's findings still ride along and only the coverage claim is
3580
+ // withdrawn — and the cost is a skipped cache seed for that file. Both cases
3581
+ // are pinned in `tests/clients/lsp/service-aux-grace.test.ts`.
3582
+ //
3583
+ // `cut_off` cannot arise on this path — there is no grace timer to end a
3584
+ // wait early — so the shapes are `answered` / `silent` / `deferred`, decided
3585
+ // by exactly the rules the grace path uses (#1458 S1: a settled promise is
3586
+ // not proof of a publication; only a `diagnosticsVersion` advance past the
3587
+ // pre-notify baseline is). `waitShape` distinguishes the two producers in
3588
+ // field data, since a `silent` row here means the auxiliary's own full
3589
+ // per-server budget lapsed rather than our ceiling cutting it short.
3590
+ //
3591
+ // A server the caller EXCLUDED (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`, #584)
3592
+ // never reaches `spawned`, so it cannot be reported here — an excluded-by-
3593
+ // design scanner is a routing decision, not a coverage gap, exactly as
3594
+ // `brokenSkippedAuxiliaryServerIds` already treats it.
3595
+ //
3596
+ // Written as `!hasTouchAuxiliaries` rather than `clientScope === "all"` so a
3597
+ // future scope that spawns auxiliaries without entering the grace wait fails
3598
+ // closed here by default instead of needing to be remembered. The #707
3599
+ // tsserver sync race can reach here with `pushWait` still pending, but it is
3600
+ // gated on `clientScope === "primary" && spawned.length === 1`, which spawns
3601
+ // no auxiliaries at all — so the per-role filter below is empty and no
3602
+ // evidence is read before its wait ends.
3603
+ //
3604
+ // `elapsedMs` and `elapsedSinceNotifyMs` are equal by construction here:
3605
+ // there is no separate post-primary aux phase to measure, so both describe
3606
+ // the one aggregate wait. Both fields are kept so a query can read either
3607
+ // producer's rows without special-casing the schema.
3608
+ //
3609
+ // The evidence is read PER PATH, through the same `readPathVersion` accessor
3610
+ // the grace path uses (#1531, landed on master while this was in review).
3611
+ // This is NOT interchangeable with `client.diagnosticsVersion`: that global
3612
+ // counter also advances for files this touch never mentions, so two
3613
+ // CONCURRENT touches sharing one auxiliary client cross-satisfy — a
3614
+ // publication for a.ts hands b.ts an unearned `answered`. That matters
3615
+ // especially here, because the highest-frequency `"all"` caller (the cascade
3616
+ // neighbour fan-out in `clients/dispatch/integration.ts`) is a
3617
+ // `Promise.allSettled` and its touches are always concurrent. Reading the
3618
+ // per-path stamp keeps this comparison on the SAME axis `baseline` was
3619
+ // captured on, and `undefined` from a double that predates the accessor fails
3620
+ // CLOSED rather than silently reverting to the global counter.
3621
+ if (!hasTouchAuxiliaries && options.collectDiagnostics === true) {
3622
+ const auxEntries = spawned.filter((entry) => entry.info.role === "auxiliary");
3623
+ if (auxEntries.length > 0) {
3624
+ const outcomes = auxEntries.map((entry) => {
3625
+ const baseline = diagnosticBaselines.get(entry.client);
3626
+ const currentPathVersion = readPathVersion(entry.client);
3627
+ const publishedEvidence = Number.isFinite(baseline) &&
3628
+ currentPathVersion !== undefined &&
3629
+ currentPathVersion > baseline;
3630
+ return {
3631
+ serverId: entry.info.id,
3632
+ outcome: deferredResyncServerIds.has(entry.info.id)
3633
+ ? "deferred"
3634
+ : publishedEvidence
3635
+ ? "answered"
3636
+ : "silent",
3637
+ publishedThisContent: auxCoversThisContent(entry.info.id),
3638
+ budgetMs: timeoutFor(entry.client.serverId),
3639
+ elapsedMs: waitedMs,
3640
+ elapsedSinceNotifyMs: waitedMs,
3641
+ };
3642
+ });
3643
+ const uncovered = auxiliaryCoverageGap(outcomes);
3644
+ if (uncovered.length > 0)
3645
+ auxUnconfirmedServerIds = uncovered;
3646
+ logLatency({
3647
+ type: "phase",
3648
+ phase: "lsp_aux_wait_outcome",
3649
+ filePath: normalizedPath,
3650
+ durationMs: waitedMs,
3651
+ metadata: { clientScope, waitShape: "aggregate", outcomes },
3652
+ });
3653
+ }
3654
+ }
2222
3655
  if (tsserverSyncConfirmed !== undefined) {
2223
3656
  // #707: the racing sync confirm won — a definitive answer well under
2224
3657
  // the push-wait budget. Not a timeout, not inconclusive.
@@ -2243,8 +3676,59 @@ export class LSPService {
2243
3676
  // Within ~20 ms of the configured budget we treat it as a timeout;
2244
3677
  // the LSP didn't beat the cap. Diagnostics that arrive late still
2245
3678
  // land in the client's cache and surface on the next edit.
2246
- diagnosticsTimedOut = true;
2247
- for (const entry of spawned) {
3679
+ //
3680
+ // #1549: WHOSE budget lapsed decides the verdict. `timeoutMs` is the MAX
3681
+ // over the servers waited on, so a slow auxiliary (opengrep declares
3682
+ // 3500ms) sets the aggregate deadline for the whole touch — and a
3683
+ // touch-wide `diagnosticsTimedOut = true` then discarded a primary answer
3684
+ // that landed in 100ms. Attribute the lapse per server instead: the
3685
+ // touch is inconclusive only when a PRIMARY produced no evidence; an
3686
+ // auxiliary that produced none becomes a named coverage gap below.
3687
+ //
3688
+ // Evidence, not promise settlement (#1458 S1): `waitForDiagnostics`
3689
+ // resolves on its own timeout, so a settled wait proves nothing. The
3690
+ // per-path publication stamp advancing past this touch's pre-notify
3691
+ // baseline (#1531) is the primary signal; a present per-file cache entry
3692
+ // is the second, because the notify this touch just sent cleared that
3693
+ // entry (`clearDiagnosticsForPath`), so a present one can only be a fresh
3694
+ // answer — the same signal #814's aggregate gate already trusts.
3695
+ //
3696
+ // Every unknown fails CLOSED: a client that exposes neither accessor
3697
+ // reads as unanswered, which for a primary is exactly the pre-#1549
3698
+ // verdict. This block can therefore only ever NARROW an inconclusive
3699
+ // touch, never create one.
3700
+ const answeredForThisTouch = (entry) => {
3701
+ const baseline = diagnosticBaselines.get(entry.client);
3702
+ const currentPathVersion = readPathVersion(entry.client);
3703
+ if (Number.isFinite(baseline) &&
3704
+ currentPathVersion !== undefined &&
3705
+ currentPathVersion > baseline) {
3706
+ return true;
3707
+ }
3708
+ try {
3709
+ return entry.client.getAllDiagnostics?.().has(normalizedPath) === true;
3710
+ }
3711
+ catch {
3712
+ // Fail closed: an unreadable cache is not evidence of an answer.
3713
+ return false;
3714
+ }
3715
+ };
3716
+ // A deferred server was never sent this content and is not waited on, so
3717
+ // it cannot have "timed out" — it is already reported as a coverage gap.
3718
+ const waited = spawned.filter((entry) => !deferredResyncServerIds.has(entry.info.id));
3719
+ const unanswered = waited.filter((entry) => !answeredForThisTouch(entry));
3720
+ diagnosticsUnansweredServerIds = unanswered.map((e) => e.info.id);
3721
+ diagnosticsUnansweredPrimaryServerIds = unanswered
3722
+ .filter((entry) => entry.info.role !== "auxiliary")
3723
+ .map((e) => e.info.id);
3724
+ // Fail-safe: a touch with no waited-on primary has no primary answer to
3725
+ // preserve, so it keeps the pre-#1549 touch-wide verdict rather than
3726
+ // absolving itself on an auxiliary's evidence.
3727
+ const hasWaitedPrimary = waited.some((entry) => entry.info.role !== "auxiliary");
3728
+ diagnosticsTimedOut =
3729
+ !hasWaitedPrimary ||
3730
+ diagnosticsUnansweredPrimaryServerIds.length > 0;
3731
+ for (const entry of unanswered) {
2248
3732
  incrementDegradationCount({
2249
3733
  kind: "lsp-diagnostics-timeout",
2250
3734
  // `info.id` is the authoritative server identity carried by
@@ -2272,6 +3756,13 @@ export class LSPService {
2272
3756
  clientScope,
2273
3757
  diagnosticsMode,
2274
3758
  timeoutMs,
3759
+ // #1549: which of those servers actually produced no evidence, and
3760
+ // whether the lapse is attributable to a primary (the touch is
3761
+ // inconclusive) or only to auxiliaries (a named coverage gap, with
3762
+ // the primary's findings intact). Without these two fields a
3763
+ // forensic sweep cannot tell the two apart at all.
3764
+ unansweredServerIds: diagnosticsUnansweredServerIds,
3765
+ attributedToPrimary: diagnosticsTimedOut,
2275
3766
  },
2276
3767
  });
2277
3768
  }
@@ -2308,9 +3799,20 @@ export class LSPService {
2308
3799
  // must have actually landed" conservatism #799 established — a
2309
3800
  // server's silence is only evidence of "clean" when we know it saw
2310
3801
  // the new content.
2311
- if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
3802
+ //
3803
+ // #1549: both the gate and its "still outstanding" set are PRIMARY-scoped.
3804
+ // An auxiliary is never asked to prove itself tier3-silent here, because an
3805
+ // auxiliary that never reported is already named as a coverage gap — and
3806
+ // requiring it to was the second half of the touch-wide conflation: a clean
3807
+ // markdown file whose marksman silence IS the answer stayed inconclusive
3808
+ // purely because an opengrep scan beside it had not finished. The auxiliary's
3809
+ // absence still costs the touch its full confirmation (`partial`); what it no
3810
+ // longer does is erase the primary's answer.
3811
+ if (diagnosticsTimedOut &&
3812
+ !primaryNotifyWriteTimedOut &&
3813
+ clientScope === "all") {
2312
3814
  try {
2313
- const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
3815
+ const outstanding = primaryEntries.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
2314
3816
  !entry.client.getAllDiagnostics().has(normalizedPath));
2315
3817
  if (outstanding.length > 0) {
2316
3818
  const snapshots = await this.getCapabilitySnapshots(filePath);
@@ -2329,6 +3831,7 @@ export class LSPService {
2329
3831
  const liveness = await Promise.all(outstanding.map((entry) => (entry.client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false)));
2330
3832
  if (liveness.every(Boolean)) {
2331
3833
  diagnosticsTimedOut = false;
3834
+ retractPrimaryTimeoutAttribution(); // #1549
2332
3835
  logLatency({
2333
3836
  type: "phase",
2334
3837
  phase: "lsp_silent_clean_confirm",
@@ -2352,6 +3855,55 @@ export class LSPService {
2352
3855
  }
2353
3856
  }
2354
3857
  }
3858
+ // #1586: THE coverage evaluation, taken ONCE, here — the last statement before
3859
+ // the merge, with no `await` between it and the drop it authorizes. Everything
3860
+ // that shares the merge's consequences reads this frozen set and never asks
3861
+ // the live predicate again.
3862
+ //
3863
+ // The review round on this change proved why the freeze has to be the unit.
3864
+ // `touchFile` awaits after the merge — `brokenSkippedAuxiliaryServerIds` on
3865
+ // every collecting touch, the tsserver sync and liveness gates on theirs — and
3866
+ // a publication landing in that window flips the live predicate. Re-asking it
3867
+ // when the coverage gap was named then un-named a scanner whose findings the
3868
+ // merge had ALREADY dropped: `.diags` missing the scanner's answer while the
3869
+ // touch claimed `confirmed`, which unblocks the `lastKnownDiagnostics` prime
3870
+ // and the `demonstratedReady` mark that `coverageGap` exists to hold shut.
3871
+ // That is #1459's blackout reading as scanned-clean — the overclaim direction,
3872
+ // and the worse one. A drop is an action; a later answer cannot undo it, so
3873
+ // the naming must be settled from the same instant that authorized it.
3874
+ const auxCoveredAtMerge = new Set(spawned
3875
+ .filter((entry) => auxCoversThisContent(entry.info.id))
3876
+ .map((entry) => entry.info.id));
3877
+ // Which DEFERRED auxiliaries this touch genuinely carries no evidence from.
3878
+ // The deferral itself only proves the gate did not send these bytes on THIS
3879
+ // touch; whether the scanner has reported on them is a content-hash question.
3880
+ const uncoveredDeferredServerIds = notifyDeferredServerIds.filter((serverId) => !auxCoveredAtMerge.has(serverId));
3881
+ // An AUXILIARY whose notify write never landed still holds the previous
3882
+ // content's findings — nothing cleared its cache — and before this change that
3883
+ // touch was blanket `inconclusive`, so no consumer read the merged array. Now
3884
+ // the primary's answer flows, which means the auxiliary's stale findings would
3885
+ // flow with it and be reported (with the previous revision's line numbers) as
3886
+ // this touch's answer. Drop them; the write failure is reported as a coverage
3887
+ // gap instead.
3888
+ // Auxiliaries only. A PRIMARY keeps #570's deliberate
3889
+ // timeout-preserves-last-known-diagnostics semantics, and its write failure
3890
+ // makes the touch inconclusive anyway, so no consumer reads the array as
3891
+ // current.
3892
+ const staleWriteAuxiliaryServerIds = spawned
3893
+ .filter((entry) => entry.info.role === "auxiliary" &&
3894
+ notifyWriteTimedOutServerIds.includes(entry.info.id) &&
3895
+ !auxCoveredAtMerge.has(entry.info.id))
3896
+ .map((entry) => entry.info.id);
3897
+ // #1586: every contribution this merge withholds, in one set. The merged
3898
+ // BINDING reads it too (#1459's door, which filtered the raw deferral set and
3899
+ // so excluded the fingerprint of a deferred-but-covered scanner whose findings
3900
+ // the merge had just kept) — a dropped contributor must lose its findings and
3901
+ // its binding together, or the merged `boundToCurrentDisk` describes bytes the
3902
+ // result no longer contains.
3903
+ const droppedAuxiliaryServerIds = new Set([
3904
+ ...uncoveredDeferredServerIds,
3905
+ ...staleWriteAuxiliaryServerIds,
3906
+ ]);
2355
3907
  // #707: when the racing sync confirm won the wait, its answer IS the
2356
3908
  // collected result — the file's real syntactic + semantic state straight
2357
3909
  // from tsserver (clean = [], dirty = real findings that a silentOnClean
@@ -2361,7 +3913,17 @@ export class LSPService {
2361
3913
  ? tsserverSyncConfirmed !== undefined
2362
3914
  ? mergeLspDiagnostics(tsserverSyncConfirmed)
2363
3915
  : mergeLspDiagnostics([
2364
- ...spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)),
3916
+ // #1459: a DEFERRED server's cache still holds the PREVIOUS
3917
+ // content's findings — the resync that would have cleared it never
3918
+ // ran. Merging them would report another revision's findings (and
3919
+ // its line numbers) as this touch's answer, the one hazard the
3920
+ // gate itself creates. Drop them; the gap is reported instead.
3921
+ // #1586: unless the scanner has since published for exactly these
3922
+ // bytes — `droppedAuxiliaryServerIds` is the one frozen answer the
3923
+ // coverage naming and the merged binding read too.
3924
+ ...spawned.flatMap((entry) => droppedAuxiliaryServerIds.has(entry.info.id)
3925
+ ? []
3926
+ : entry.client.getDiagnostics(filePath)),
2365
3927
  ...carriedAuxiliary.flatMap((entry) => entry.diags),
2366
3928
  ])
2367
3929
  : undefined;
@@ -2399,6 +3961,7 @@ export class LSPService {
2399
3961
  // Sync answered — confirmed result (clean or with diagnostics).
2400
3962
  // Clear the timed-out flag so the touch is no longer inconclusive.
2401
3963
  diagnosticsTimedOut = false;
3964
+ retractPrimaryTimeoutAttribution(); // #1549
2402
3965
  syncConfirmed = true;
2403
3966
  collected = syncResult.length > 0
2404
3967
  ? mergeLspDiagnostics(syncResult)
@@ -2457,7 +4020,10 @@ export class LSPService {
2457
4020
  // into the general one once both have soaked, but that's a separate,
2458
4021
  // lower-risk follow-up, not bundled into this fix.
2459
4022
  if (diagnosticsTimedOut &&
2460
- !notifyWriteTimedOut &&
4023
+ // #1549: primary-scoped, like the aggregate gate. `spawned.length === 1`
4024
+ // below means the one server IS the primary, so this is the same condition
4025
+ // written in the vocabulary the rest of the merge now uses.
4026
+ !primaryNotifyWriteTimedOut &&
2461
4027
  !tsserverSyncEligible &&
2462
4028
  clientScope === "primary" &&
2463
4029
  spawned.length === 1 &&
@@ -2474,6 +4040,7 @@ export class LSPService {
2474
4040
  const alive = await (spawned[0].client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false);
2475
4041
  if (alive) {
2476
4042
  diagnosticsTimedOut = false;
4043
+ retractPrimaryTimeoutAttribution(); // #1549
2477
4044
  if (collected !== undefined)
2478
4045
  collected = mergeLspDiagnostics([]);
2479
4046
  logLatency({
@@ -2496,32 +4063,138 @@ export class LSPService {
2496
4063
  // behavior.
2497
4064
  }
2498
4065
  }
2499
- // A touch is inconclusive when EITHER the notify write or the
2500
- // diagnostics wait hit their deadline for ANY of the spawned servers
2501
- // (these flags are touch-wide, covering the whole `Promise.all` over
2502
- // `spawned` — see the field doc on the return type). We deliberately
2503
- // err toward caution here: `collected` merges diagnostics across every
2504
- // spawned server, so even a partial timeout (e.g. a slow auxiliary
2505
- // while the primary answered) means the merged result may be missing
2506
- // findings that just hadn't arrived yet — it must not be trusted as a
2507
- // confirmed answer.
2508
- const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
2509
- // #1470: an auxiliary whose push wait was CUT OFF by the aux grace timer
2510
- // (R8/#714) contributed exactly as much evidence about this file as one that
2511
- // went silent inside its own budget — none. A hung opengrep resolved
2512
- // `confirmation: "confirmed"` and read as confirmed-clean on the security
2513
- // lane. (The SILENT case still does, and is NOT addressed here: a scanner
2514
- // that settles inside its own budget without publishing also yields an
2515
- // unqualified confirmation. Same #533 class, same lane, different door —
2516
- // filed as #1493, deliberately untouched by this change.)
4066
+ // #1549: a touch is inconclusive when a PRIMARY's notify write or the
4067
+ // diagnostics wait hit their deadline. Both inputs are primary-scoped now.
4068
+ //
4069
+ // The rule this replaced — `notifyWriteTimedOut || diagnosticsTimedOut`, both
4070
+ // flags touch-wide over every spawned server — discarded every good answer in
4071
+ // the touch whenever one auxiliary was slow. `timeoutMs` is the MAX over the
4072
+ // servers waited on, so opengrep's 3500ms budget set the deadline for the whole
4073
+ // touch and a typescript answer that landed in 100ms read as "nothing is known
4074
+ // about this file". Measured over 6,079 cascade neighbour sweeps: 97.6%
4075
+ // inconclusive, against 15% for ordinary edit-time touches in the same window.
4076
+ //
4077
+ // The caution the old comment argued for is preserved, in the honest place: the
4078
+ // merged result IS missing whatever the unreporting auxiliary would have said,
4079
+ // so the touch withdraws its claim of full coverage (`confirmation: "partial"`
4080
+ // plus `unconfirmedServerIds`, below) and every consumer that treats
4081
+ // confirmation as proof of coverage still fails closed. What it no longer does
4082
+ // is throw away the primary's answer, which is the #533 honesty doctrine
4083
+ // cutting both ways.
4084
+ const verdict = resolveTouchVerdict({
4085
+ primaryNotifyWriteTimedOutServerIds,
4086
+ diagnosticsTimedOut,
4087
+ diagnosticsUnansweredServerIds: diagnosticsUnansweredPrimaryServerIds,
4088
+ });
4089
+ const inconclusive = verdict.inconclusive;
4090
+ // #1470/#1493: an auxiliary whose push wait was CUT OFF by the aux grace
4091
+ // timer (R8/#714) contributed exactly as much evidence about this file as one
4092
+ // that went silent inside its own budget — none. Both now narrow the
4093
+ // confirmation, through the one `auxiliaryCoverageGap` policy. A hung or
4094
+ // silent opengrep used to resolve `confirmation: "confirmed"` and read as
4095
+ // confirmed-clean on the security lane; the silent half survived #1470
4096
+ // because it only tripped `diagnosticsTimedOut` when it was the ONLY
4097
+ // auxiliary, so a fast sibling hid it (#1493).
2517
4098
  // This does NOT flip the touch to inconclusive: that would discard a
2518
4099
  // primary answer that IS trustworthy (#533 honesty doctrine cuts both ways —
2519
4100
  // overclaiming and underclaiming are both dishonest). Instead the confirmation
2520
4101
  // is NARROWED: `"partial"`, naming the servers it does not speak for, so every
2521
4102
  // consumer that treats confirmation as proof of coverage fails closed while
2522
4103
  // the primary's findings still flow.
2523
- const unconfirmedServerIds = auxCutOffServerIds ?? [];
4104
+ // #1459: two more doors into the same room, and they open BEFORE any wait —
4105
+ // so `auxiliaryCoverageGap` (which reads wait outcomes) cannot see either on
4106
+ // its own. A scanner whose breaker was open never attached, and one whose
4107
+ // resync the fan-out gate deferred never received this content.
4108
+ //
4109
+ // The deferred ids are unioned in rather than left to the aux-wait policy on
4110
+ // purpose: an aux outcome row requires an auxiliary to have been SPAWNED, and a
4111
+ // breaker-skipped scanner never was. #1533: an `"all"`-scope sweep emits outcome
4112
+ // rows now too, so a spawned-but-deferred server arrives through BOTH routes as
4113
+ // outcome `"deferred"` — where a stored publication
4114
+ // for these exact bytes can still exempt it — so the Set dedups rather than
4115
+ // double-reports, and #1493's content-hash exemption is not bypassed here:
4116
+ // a deferred aux is only unioned in because the gate itself proves it was
4117
+ // never sent this content.
4118
+ const brokenSkippedServerIds = collected !== undefined
4119
+ ? await this.brokenSkippedAuxiliaryServerIds(filePath, clientScope, options, spawned)
4120
+ : [];
4121
+ // #1549: the fourth door, and the one this issue opened. An auxiliary that
4122
+ // missed a deadline no longer makes the touch inconclusive, so it MUST arrive
4123
+ // here instead — otherwise the fix would launder a scanner blackout into a
4124
+ // clean bill of health, which is the #1459/#1493 false-clean pointing the other
4125
+ // way. Two shapes reach this and no aux outcome row: an auxiliary whose notify
4126
+ // write timed out or rejected, and one that produced no publication evidence
4127
+ // when the wait lapsed (including on a NON-collecting touch, which derives no
4128
+ // outcome rows at all). The content-bound exemption is honored through the same
4129
+ // `auxCoversThisContent` predicate the merge uses — one rule, so a scanner
4130
+ // cannot be named uncovered while its findings ride along in `.diags`, or the
4131
+ // reverse. The Set dedups against the ids `auxiliaryCoverageGap` reported.
4132
+ const auxNoAnswerServerIds = spawned
4133
+ .filter((entry) => entry.info.role === "auxiliary" &&
4134
+ (diagnosticsUnansweredServerIds.includes(entry.info.id) ||
4135
+ notifyWriteTimedOutServerIds.includes(entry.info.id)) &&
4136
+ !auxCoveredAtMerge.has(entry.info.id))
4137
+ .map((entry) => entry.info.id);
4138
+ // #1586: whatever the doors contributed, the RESULT's coverage claim is
4139
+ // settled from the MERGE's frozen evaluation — never a fresh one, which is
4140
+ // what made this an overclaim in review (see `auxCoveredAtMerge`). The
4141
+ // per-door filters above are not redundant with this one: they shape the
4142
+ // `lsp_scanner_coverage_gap` fields, which each answer "what did THIS door
4143
+ // see", while this settles "what does the touch speak for". It matters most
4144
+ // for `auxUnconfirmedServerIds`, decided when the aux wait ended and therefore
4145
+ // strictly BEFORE the merge — reconciling it here is what keeps a scanner from
4146
+ // being named while the merge kept its findings. A breaker-skipped scanner
4147
+ // never reached `spawned`, so it is not in the covered set and stays named:
4148
+ // fail closed.
4149
+ const unconfirmedServerIds = [
4150
+ ...new Set([
4151
+ ...(auxUnconfirmedServerIds ?? []),
4152
+ ...auxNoAnswerServerIds,
4153
+ ...uncoveredDeferredServerIds,
4154
+ ...brokenSkippedServerIds,
4155
+ ]),
4156
+ ].filter((serverId) => !auxCoveredAtMerge.has(serverId));
2524
4157
  const coverageGap = unconfirmedServerIds.length > 0;
4158
+ // The record that proves a blackout is no longer read as clean: one row per
4159
+ // touch that a scanner did not cover, naming the scanner and the reason.
4160
+ if (brokenSkippedServerIds.length > 0 ||
4161
+ uncoveredDeferredServerIds.length > 0 ||
4162
+ // #1549: the auxiliary deadline misses that used to surface as a blanket
4163
+ // `inconclusive` need their own row now that the touch reports usable
4164
+ // findings — otherwise the fix would remove the only record of the blackout.
4165
+ auxNoAnswerServerIds.length > 0) {
4166
+ for (const serverId of unconfirmedServerIds) {
4167
+ const reasons = [
4168
+ brokenSkippedServerIds.includes(serverId) && "breaker skip",
4169
+ uncoveredDeferredServerIds.includes(serverId) && "deferred resync",
4170
+ auxNoAnswerServerIds.includes(serverId) && "no diagnostics answer",
4171
+ ].filter(Boolean);
4172
+ incrementDegradationCount({
4173
+ kind: "lsp-scanner-coverage-gap",
4174
+ subject: `${serverId}:${normalizedPath}`,
4175
+ reason: reasons.join(", ") || "scanner coverage gap",
4176
+ });
4177
+ }
4178
+ logLatency({
4179
+ type: "phase",
4180
+ phase: "lsp_scanner_coverage_gap",
4181
+ filePath: normalizedPath,
4182
+ durationMs: Date.now() - startedAt,
4183
+ metadata: {
4184
+ source,
4185
+ clientScope,
4186
+ ...(brokenSkippedServerIds.length > 0 && { brokenSkippedServerIds }),
4187
+ // #1586: the deferrals this touch is actually uncovered for. The raw
4188
+ // gate action keeps its own record in `lsp_notify_resync_deferred`;
4189
+ // this row exists to prove a blackout, and a scanner already bound to
4190
+ // these bytes is not one.
4191
+ ...(uncoveredDeferredServerIds.length > 0 && {
4192
+ deferredResyncServerIds: uncoveredDeferredServerIds,
4193
+ }),
4194
+ ...(auxNoAnswerServerIds.length > 0 && { auxNoAnswerServerIds }),
4195
+ },
4196
+ });
4197
+ }
2525
4198
  // #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
2526
4199
  // "actually warm" signal `ensureWarmForSweep` waits for — mark every
2527
4200
  // spawned server so a later sweep in this session sees the check as a
@@ -2533,9 +4206,10 @@ export class LSPService {
2533
4206
  // timed out are skipped here (rather than gating the whole loop on the
2534
4207
  // file-level `inconclusive`).
2535
4208
  //
2536
- // #1470: same per-server reasoning for a CUT-OFF auxiliary. "Demonstrated
2537
- // ready" means this server answered for this file; an auxiliary our grace
2538
- // timer cut off demonstrably did not.
4209
+ // #1470/#1493: same per-server reasoning for an auxiliary that contributed
4210
+ // no evidence. "Demonstrated ready" means this server answered for this
4211
+ // file; an auxiliary our grace timer cut off, or one that stayed silent
4212
+ // through its own budget, demonstrably did not.
2539
4213
  //
2540
4214
  // NO TEST PINS THIS LINE, and that is a property of today's readers rather
2541
4215
  // than a coverage gap: `ensureWarmForSweep` filters `role === "auxiliary"`
@@ -2545,13 +4219,23 @@ export class LSPService {
2545
4219
  // because the mark's meaning is "this server answered", and the moment any
2546
4220
  // reader stops filtering auxiliaries out, marking a cut-off scanner warm
2547
4221
  // would let it skip a warm-up it never earned.
4222
+ //
4223
+ // #1549: `diagnosticsTimedOut` is primary-attributed now, so this loop can be
4224
+ // reached with an auxiliary that never answered — including on a NON-COLLECTING
4225
+ // touch, which derives no aux wait-outcome rows. `unconfirmedServerIds` covers
4226
+ // that case because `auxNoAnswerServerIds` is computed for every touch, not only
4227
+ // a collecting one; an unheard scanner is therefore already excluded here and
4228
+ // needs no separate guard. A primary cannot reach this loop unheard at all: it
4229
+ // would have set `diagnosticsTimedOut`, and the gates that clear that flag
4230
+ // retract its attribution precisely because they certified its silence AS the
4231
+ // answer (`retractPrimaryTimeoutAttribution`).
2548
4232
  const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
2549
- const cutOffServerIds = new Set(unconfirmedServerIds);
4233
+ const uncoveredServerIds = new Set(unconfirmedServerIds);
2550
4234
  if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
2551
4235
  for (const entry of spawned) {
2552
4236
  if (notifyTimedOutServerIds.has(entry.info.id))
2553
4237
  continue;
2554
- if (cutOffServerIds.has(entry.info.id))
4238
+ if (uncoveredServerIds.has(entry.info.id))
2555
4239
  continue;
2556
4240
  const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
2557
4241
  if (key)
@@ -2567,8 +4251,8 @@ export class LSPService {
2567
4251
  // empty `collected` must never erase a previously-confirmed non-empty
2568
4252
  // record (that's the #570 bug — a timeout silently reporting as clean
2569
4253
  // and wiping out known-good diagnostic state).
2570
- // #1470: a PARTIAL touch is the same hazard wearing a different flag. Its
2571
- // merged array is missing whatever the cut-off auxiliary would have said, so
4254
+ // #1470/#1493: a PARTIAL touch is the same hazard wearing a different flag.
4255
+ // Its merged array is missing whatever the unreporting auxiliary would have said, so
2572
4256
  // priming the cache with it would let `actionable-warnings`' hash-guarded
2573
4257
  // read replay a partially-covered result as an authoritative observation —
2574
4258
  // and an empty one would DELETE a previously-confirmed record on the strength
@@ -2597,12 +4281,23 @@ export class LSPService {
2597
4281
  const result = { diags: collected ?? [] };
2598
4282
  if (collected !== undefined && inconclusive) {
2599
4283
  result.inconclusive = true;
4284
+ // #1549: name the primary that produced the verdict and which deadline it
4285
+ // missed, so a forensic sweep reads the cause instead of inferring it from
4286
+ // duration histograms. Absent ids on an inconclusive touch mean the
4287
+ // attribution was not derivable (a client with no per-path publication
4288
+ // stamp) — honest, and the same fail-closed verdict as before.
4289
+ if (verdict.inconclusiveServerIds) {
4290
+ result.inconclusiveServerIds = verdict.inconclusiveServerIds;
4291
+ }
4292
+ result.inconclusiveReason = verdict.inconclusiveReason;
2600
4293
  }
2601
4294
  else if (collected !== undefined && coverageGap) {
2602
- // #1470: narrowed, not collapsed. The primary's findings ride along in
2603
- // `.diags` exactly as before; what changes is that the touch now states
2604
- // which servers it does not speak for, so no consumer can read this as a
2605
- // full clean bill of health.
4295
+ // #1470/#1493: narrowed, not collapsed. Reached for EITHER no-answer
4296
+ // shape — a cut-off auxiliary or a silent one with nothing published for
4297
+ // this content. The primary's findings ride along in `.diags` exactly as
4298
+ // before; what changes is that the touch now states which servers it does
4299
+ // not speak for, so no consumer can read this as a full clean bill of
4300
+ // health.
2606
4301
  result.confirmation = "partial";
2607
4302
  result.unconfirmedServerIds = [...unconfirmedServerIds];
2608
4303
  }
@@ -2630,7 +4325,17 @@ export class LSPService {
2630
4325
  // partially-mocked client) yields "unknown" rather than throwing —
2631
4326
  // unknown preserves pre-#1095 behavior for that contributor.
2632
4327
  [
2633
- ...spawned.map((entry) => entry.client.getDiagnosticBinding?.(filePath)),
4328
+ // #1459/#1549: a contributor whose findings the merge DROPPED must
4329
+ // not decide the merged verdict either — its binding describes
4330
+ // bytes this result no longer contains.
4331
+ // #1586: read off the same frozen set the drop used. Filtering the
4332
+ // raw deferral set instead excluded the fingerprint of a
4333
+ // deferred-but-COVERED scanner whose findings the merge had just
4334
+ // kept, which diverges whenever the primary is version-less and
4335
+ // that scanner is the only contributor with a fingerprint.
4336
+ ...spawned
4337
+ .filter((entry) => !droppedAuxiliaryServerIds.has(entry.info.id))
4338
+ .map((entry) => entry.client.getDiagnosticBinding?.(filePath)),
2634
4339
  ...carriedAuxiliary.map((entry) => entry.binding),
2635
4340
  ]);
2636
4341
  result.binding = binding;
@@ -2671,6 +4376,16 @@ export class LSPService {
2671
4376
  }),
2672
4377
  diagnosticsTimedOut,
2673
4378
  inconclusive,
4379
+ // #1549: the attribution the issue's observability contract asks for —
4380
+ // WHICH primary made the touch inconclusive and which deadline it missed
4381
+ // (`notify-write` vs `diagnostics-wait`, or `mixed`). Absent on a
4382
+ // conclusive touch, which blames nobody.
4383
+ ...(verdict.inconclusiveServerIds && {
4384
+ inconclusiveServerIds: verdict.inconclusiveServerIds,
4385
+ }),
4386
+ ...(verdict.inconclusiveReason && {
4387
+ inconclusiveReason: verdict.inconclusiveReason,
4388
+ }),
2674
4389
  // #1470: the touch's own honesty verdict, so a `cut_off` row in
2675
4390
  // `lsp_aux_wait_outcome` can be joined to the touch that produced it and
2676
4391
  // shown NOT to have claimed confirmation for that server's coverage.
@@ -2683,6 +4398,32 @@ export class LSPService {
2683
4398
  // Absent when no aux was cut off. These servers' diagnostics are
2684
4399
  // advisory-only and will surface on the next edit from their cache.
2685
4400
  ...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
4401
+ // #1493: the full set the confirmation was narrowed on — the cut-off
4402
+ // ids plus every auxiliary that stayed silent with nothing published
4403
+ // for this content. Absent when the touch speaks for every server.
4404
+ // This is the join key for the issue's observability contract: a
4405
+ // `silent` row in `lsp_aux_wait_outcome` must appear here, on a touch
4406
+ // whose `confirmation` is `"partial"`.
4407
+ ...(auxUnconfirmedServerIds !== undefined && {
4408
+ auxUnconfirmedServerIds,
4409
+ }),
4410
+ // #1459: scanners this touch does not speak for because their breaker
4411
+ // was open, or because the resync gate deferred their write. Separate
4412
+ // fields because these two doors open BEFORE any wait, so neither can
4413
+ // appear in an `lsp_aux_wait_outcome` row on the sweep path. Absent
4414
+ // when every configured scanner got this content — #1586 included: a
4415
+ // deferred scanner already bound to these bytes is covered, so it is not
4416
+ // named here either. The gate's own action is recorded regardless, in
4417
+ // `lsp_notify_resync_deferred`.
4418
+ ...(brokenSkippedServerIds.length > 0 && { brokenSkippedServerIds }),
4419
+ ...(uncoveredDeferredServerIds.length > 0 && {
4420
+ deferredResyncServerIds: uncoveredDeferredServerIds,
4421
+ }),
4422
+ // #1549: auxiliaries whose own deadline lapsed — the wait produced no
4423
+ // publication, or the notify write never landed. Distinct from the fields
4424
+ // above (a different door) and from `auxUnconfirmedServerIds` (derived
4425
+ // from aux wait-outcome rows, which a non-collecting touch never emits).
4426
+ ...(auxNoAnswerServerIds.length > 0 && { auxNoAnswerServerIds }),
2686
4427
  },
2687
4428
  });
2688
4429
  return result;
@@ -2918,8 +4659,15 @@ export class LSPService {
2918
4659
  this.lastKnownContentHash.delete(normalizedPath);
2919
4660
  return merged;
2920
4661
  }
4662
+ /**
4663
+ * Delegates to {@link hashDiagnosticContent} rather than re-hashing here. Every
4664
+ * comparison this hash takes part in (`publishedThisContent`, the carried-aux
4665
+ * check, the last-known content guard) is against a hash the client produced
4666
+ * with that function, so the two implementations must agree byte for byte —
4667
+ * a duplicate is a silent divergence waiting for one of them to be tuned.
4668
+ */
2921
4669
  hashContent(content) {
2922
- return createHash("sha256").update(content).digest("hex");
4670
+ return hashDiagnosticContent(content);
2923
4671
  }
2924
4672
  /**
2925
4673
  * #1095: compose the content `binding` for a merged diagnostics result across
@@ -2962,6 +4710,9 @@ export class LSPService {
2962
4710
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2963
4711
  if (!spawned)
2964
4712
  return [];
4713
+ if (!spawned.client.getOperationSupport().definition) {
4714
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for definition");
4715
+ }
2965
4716
  return spawned.client.definition(filePath, line, character);
2966
4717
  }
2967
4718
  /**
@@ -2971,6 +4722,9 @@ export class LSPService {
2971
4722
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2972
4723
  if (!spawned)
2973
4724
  return [];
4725
+ if (!spawned.client.getOperationSupport().typeDefinition) {
4726
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for typeDefinition");
4727
+ }
2974
4728
  return spawned.client.typeDefinition(filePath, line, character);
2975
4729
  }
2976
4730
  /**
@@ -2980,6 +4734,9 @@ export class LSPService {
2980
4734
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2981
4735
  if (!spawned)
2982
4736
  return [];
4737
+ if (!spawned.client.getOperationSupport().declaration) {
4738
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for declaration");
4739
+ }
2983
4740
  return spawned.client.declaration(filePath, line, character);
2984
4741
  }
2985
4742
  /**
@@ -2989,6 +4746,9 @@ export class LSPService {
2989
4746
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2990
4747
  if (!spawned)
2991
4748
  return [];
4749
+ if (!spawned.client.getOperationSupport().references) {
4750
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for references");
4751
+ }
2992
4752
  return spawned.client.references(filePath, line, character, includeDeclaration);
2993
4753
  }
2994
4754
  /**
@@ -2998,6 +4758,9 @@ export class LSPService {
2998
4758
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2999
4759
  if (!spawned)
3000
4760
  return null;
4761
+ if (!spawned.client.getOperationSupport().hover) {
4762
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for hover");
4763
+ }
3001
4764
  return spawned.client.hover(filePath, line, character);
3002
4765
  }
3003
4766
  /**
@@ -3007,6 +4770,9 @@ export class LSPService {
3007
4770
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
3008
4771
  if (!spawned)
3009
4772
  return null;
4773
+ if (!spawned.client.getOperationSupport().signatureHelp) {
4774
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for signatureHelp");
4775
+ }
3010
4776
  return spawned.client.signatureHelp(filePath, line, character);
3011
4777
  }
3012
4778
  /**
@@ -3016,44 +4782,116 @@ export class LSPService {
3016
4782
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
3017
4783
  if (!spawned)
3018
4784
  return [];
4785
+ if (!spawned.client.getOperationSupport().documentSymbol) {
4786
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for documentSymbol");
4787
+ }
3019
4788
  return spawned.client.documentSymbol(filePath);
3020
4789
  }
4790
+ /**
4791
+ * Resolves "the target client" for a workspace-scope query that has no
4792
+ * filePath to route through `getClientForFile`. `state.clients` is keyed
4793
+ * `${serverId}:${root}` in spawn order, not role order, so an auxiliary
4794
+ * scanner (ast-grep, opengrep, zizmor, ...) that happens to spawn first in
4795
+ * a polyglot workspace used to win every no-filePath query outright (#1812
4796
+ * — a supporting primary server spawned later never got a look-in). Scans
4797
+ * for the first LIVE client matching `predicate` (when given), preferring
4798
+ * any primary (non-`"auxiliary"` role) match over an auxiliary one — the
4799
+ * same primary-over-auxiliary preference `getClientForFile` encodes via
4800
+ * its `role !== "auxiliary"` filter (this file, `getClientForFile`) and
4801
+ * `getAliveServerIds` groups by. `isAlive()` is required for BOTH the
4802
+ * preferred and fallback candidate — mirrors `getCapabilitySnapshots`'s
4803
+ * own no-filePath branch (this file, ~line 5868), whose liveness filter
4804
+ * this helper otherwise duplicates; without it a dead primary would win
4805
+ * over a live, answering auxiliary. Role is read from the map key's
4806
+ * `serverId` prefix against `LSP_SERVERS`, the same single source of
4807
+ * truth `getCapabilitySnapshots` already parses that key from — never a
4808
+ * second, hand-rolled role table. A `serverId` prefix absent from
4809
+ * `LSP_SERVERS` (should not happen in practice — every spawned client's
4810
+ * key is built from a known server's `id`) resolves to `role === undefined`,
4811
+ * which falls through to the primary branch: unknown treated as primary,
4812
+ * never silently dropped. Returns undefined only when NO live client
4813
+ * (primary or auxiliary) matches.
4814
+ */
4815
+ selectWorkspaceScopeClient(predicate) {
4816
+ let auxFallback;
4817
+ for (const [key, client] of this.state.clients) {
4818
+ if (!client.isAlive())
4819
+ continue;
4820
+ if (predicate && !predicate(client))
4821
+ continue;
4822
+ const separator = key.indexOf(":");
4823
+ const serverId = separator >= 0 ? key.slice(0, separator) : key;
4824
+ const role = LSP_SERVERS.find((s) => s.id === serverId)?.role;
4825
+ if (role === "auxiliary") {
4826
+ if (!auxFallback)
4827
+ auxFallback = { client, serverId };
4828
+ continue;
4829
+ }
4830
+ return { client, serverId };
4831
+ }
4832
+ return auxFallback;
4833
+ }
3021
4834
  /**
3022
4835
  * Navigation: workspace-wide symbol search
4836
+ *
4837
+ * #1789: gated on the target server's advertised `workspaceSymbolProvider`
4838
+ * (the same `getOperationSupport().workspaceSymbol` single source of truth
4839
+ * `lsp-document-symbols.ts`'s documentSymbol gate reads — see clients/
4840
+ * lsp-document-symbols.ts:50).
4841
+ *
4842
+ * #1812: without a path, the no-filePath branch used to stop at
4843
+ * `state.clients`' first entry by insertion order regardless of whether it
4844
+ * supported `workspace/symbol` — an auxiliary spawned first silently ate
4845
+ * every query with `[]` and zero requests, even when a supporting primary
4846
+ * server was already spawned too. `selectWorkspaceScopeClient` now scans
4847
+ * for the first client that DOES support it, preferring a primary over an
4848
+ * auxiliary; only when none of the spawned clients support it does this
4849
+ * fall back to `[]`.
3023
4850
  */
3024
- async workspaceSymbol(query, filePath) {
4851
+ async workspaceSymbol(query, filePath, attribution) {
3025
4852
  if (filePath) {
3026
4853
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
3027
4854
  if (!spawned)
3028
4855
  return [];
4856
+ if (!spawned.client.getOperationSupport().workspaceSymbol)
4857
+ return [];
3029
4858
  return spawned.client.workspaceSymbol(query);
3030
4859
  }
3031
- // Use the first active client for workspace-level queries
3032
- const clients = Array.from(this.state.clients.values());
3033
- if (clients.length === 0)
4860
+ const target = this.selectWorkspaceScopeClient((client) => client.getOperationSupport().workspaceSymbol);
4861
+ if (!target)
3034
4862
  return [];
3035
- return clients[0].workspaceSymbol(query);
4863
+ if (attribution)
4864
+ attribution.workspaceSymbol = target.serverId;
4865
+ return target.client.workspaceSymbol(query);
3036
4866
  }
3037
4867
  /**
3038
4868
  * Commands advertised for workspace/executeCommand. If filePath is given,
3039
- * the server for that file; otherwise the first active client.
4869
+ * the server for that file; otherwise the first active client, preferring
4870
+ * a primary over an auxiliary scanner spawned first (#1812 sweep — see
4871
+ * `selectWorkspaceScopeClient`).
3040
4872
  */
3041
- async getAdvertisedCommands(filePath) {
4873
+ async getAdvertisedCommands(filePath, attribution) {
3042
4874
  if (filePath) {
3043
4875
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
3044
4876
  if (!spawned)
3045
4877
  return [];
3046
4878
  return spawned.client.getAdvertisedCommands();
3047
4879
  }
3048
- const first = this.state.clients.values().next().value;
3049
- return first ? first.getAdvertisedCommands() : [];
4880
+ const first = this.selectWorkspaceScopeClient();
4881
+ if (!first)
4882
+ return [];
4883
+ if (attribution)
4884
+ attribution.getAdvertisedCommands = first.serverId;
4885
+ return first.client.getAdvertisedCommands();
3050
4886
  }
3051
4887
  /**
3052
4888
  * Run a server command via workspace/executeCommand (hardened: allowlisted by
3053
4889
  * advertisement in the client). If filePath is given, target that file's
3054
- * server; otherwise the first active client.
4890
+ * server; otherwise the first active client, preferring a primary over an
4891
+ * auxiliary scanner spawned first (#1812 sweep — see
4892
+ * `selectWorkspaceScopeClient`).
3055
4893
  */
3056
- async executeCommand(filePath, command, args, mutationContext) {
4894
+ async executeCommand(filePath, command, args, mutationContext, attribution) {
3057
4895
  if (filePath) {
3058
4896
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
3059
4897
  if (!spawned) {
@@ -3061,16 +4899,74 @@ export class LSPService {
3061
4899
  }
3062
4900
  return spawned.client.executeCommand(command, args, mutationContext);
3063
4901
  }
3064
- const first = this.state.clients.values().next().value;
4902
+ const first = this.selectWorkspaceScopeClient();
3065
4903
  if (!first)
3066
4904
  return { executed: false, reason: "no active LSP server" };
3067
- return first.executeCommand(command, args, mutationContext);
4905
+ if (attribution)
4906
+ attribution.executeCommand = first.serverId;
4907
+ return first.client.executeCommand(command, args, mutationContext);
4908
+ }
4909
+ /**
4910
+ * #1640: run a read-only probe command against a server that is ALREADY
4911
+ * running for this file. Two hard guarantees the mutation-channel
4912
+ * `executeCommand` above cannot give a render-path probe:
4913
+ *
4914
+ * - **Never spawns.** It resolves the already-connected client map the way
4915
+ * `isServerAliveForFile` does, instead of routing through
4916
+ * `getClientForFile` → `ensureClientForServer`. A probe that spawns a
4917
+ * language-server fleet to answer a question about how to RENDER a
4918
+ * diagnostic is a cost the caller never asked for — and under warm attach
4919
+ * the freshly spawned server's answer would not even be the warm session's
4920
+ * answer.
4921
+ * - **Never opens the mutation window.** `executeReadOnlyCommand` leaves
4922
+ * `serverEditsAllowed` and `activeMutationContext` alone, so an in-flight
4923
+ * real command's mutation context survives a concurrent probe.
4924
+ *
4925
+ * Returns `{executed:false}` — never a thrown error and never a spawn — when
4926
+ * no live client owns the file. Callers must read that as UNKNOWN.
4927
+ */
4928
+ async executeReadOnlyCommandOnLiveClient(filePath, command, args) {
4929
+ if (this.checkDestroyed()) {
4930
+ return { executed: false, reason: "lsp service destroyed" };
4931
+ }
4932
+ for (const server of getServersForFileWithConfig(filePath)) {
4933
+ const root = await this.resolveServerRoot(server, filePath);
4934
+ if (!root)
4935
+ continue;
4936
+ const entry = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
4937
+ if (!entry?.isAlive())
4938
+ continue;
4939
+ const run = entry.executeReadOnlyCommand;
4940
+ if (typeof run !== "function")
4941
+ continue;
4942
+ return run.call(entry, command, args);
4943
+ }
4944
+ return { executed: false, reason: "no live LSP server for file" };
3068
4945
  }
3069
4946
  /**
3070
4947
  * Capability snapshot for LSP operations.
3071
- * If filePath is provided, probes that server; otherwise uses first active client.
4948
+ * If filePath is provided, probes that server. Without a filePath the
4949
+ * snapshot describes the whole workspace, so each capability is ORed
4950
+ * across every client `selectWorkspaceScopeClient` would consider.
4951
+ *
4952
+ * #1846: the no-filePath branch used to report ONE client's capabilities,
4953
+ * whichever `selectWorkspaceScopeClient()` returned with no predicate. In
4954
+ * a multi-primary workspace (say `json` spawned before `typescript`), a
4955
+ * first client that does not advertise `workspaceSymbolProvider` reported
4956
+ * the operation unsupported even though a later client advertises it. The
4957
+ * tool layer then refused the call before `workspaceSymbol()` — which
4958
+ * #1812 taught to find the supporting client — was ever reached
4959
+ * (tools/lsp-navigation.ts, the `runWorkspaceSymbolOperation` gate).
4960
+ *
4961
+ * Each capability resolves through `selectWorkspaceScopeClient` with a
4962
+ * per-capability predicate, so this answer is built from the SAME liveness
4963
+ * and primary-over-auxiliary rules that route the operation itself (see
4964
+ * `selectWorkspaceScopeClient`, this file). A dead client therefore cannot
4965
+ * contribute a capability nobody can execute. Capabilities the base client
4966
+ * already reports true are skipped, so the extra scans only run for the
4967
+ * capabilities it lacks.
3072
4968
  */
3073
- async getOperationSupport(filePath) {
4969
+ async getOperationSupport(filePath, attribution) {
3074
4970
  if (filePath) {
3075
4971
  const spawned = await this.getClientForFile(filePath);
3076
4972
  if (!spawned)
@@ -3080,19 +4976,36 @@ export class LSPService {
3080
4976
  return null;
3081
4977
  return getter();
3082
4978
  }
3083
- const first = this.state.clients.values().next().value;
4979
+ const readable = (client) => typeof client.getOperationSupport === "function";
4980
+ const first = this.selectWorkspaceScopeClient(readable);
3084
4981
  if (!first)
3085
4982
  return null;
3086
- const getter = first.getOperationSupport;
3087
- if (typeof getter !== "function")
3088
- return null;
3089
- return getter();
4983
+ const aggregate = { ...first.client.getOperationSupport() };
4984
+ const contributors = {};
4985
+ for (const capability of Object.keys(aggregate)) {
4986
+ if (aggregate[capability]) {
4987
+ contributors[capability] = first.serverId;
4988
+ continue;
4989
+ }
4990
+ const supporter = this.selectWorkspaceScopeClient((client) => readable(client) && Boolean(client.getOperationSupport()[capability]));
4991
+ if (supporter) {
4992
+ aggregate[capability] = true;
4993
+ contributors[capability] = supporter.serverId;
4994
+ }
4995
+ }
4996
+ if (attribution) {
4997
+ attribution.getOperationSupport = {
4998
+ baseClientId: first.serverId,
4999
+ contributors,
5000
+ };
5001
+ }
5002
+ return aggregate;
3090
5003
  }
3091
5004
  /**
3092
5005
  * Capability snapshot for workspace diagnostics support.
3093
5006
  * If filePath is provided, probes that server; otherwise uses first active client.
3094
5007
  */
3095
- async getCapabilitySnapshots(filePath) {
5008
+ async getCapabilitySnapshots(filePath, attribution) {
3096
5009
  if (this.checkDestroyed())
3097
5010
  return [];
3098
5011
  const snapshots = [];
@@ -3132,9 +5045,17 @@ export class LSPService {
3132
5045
  launchVariant: client.getLaunchVariant?.(),
3133
5046
  });
3134
5047
  }
5048
+ if (attribution) {
5049
+ attribution.getCapabilitySnapshots = {
5050
+ clientIds: snapshots
5051
+ .slice(0, WORKSPACE_ATTRIBUTION_CLIENT_CAP)
5052
+ .map((snapshot) => snapshot.serverId),
5053
+ clientCount: snapshots.length,
5054
+ };
5055
+ }
3135
5056
  return snapshots;
3136
5057
  }
3137
- async getWorkspaceDiagnosticsSupport(filePath) {
5058
+ async getWorkspaceDiagnosticsSupport(filePath, attribution) {
3138
5059
  if (filePath) {
3139
5060
  const spawned = await this.getClientForFile(filePath);
3140
5061
  if (!spawned)
@@ -3144,13 +5065,15 @@ export class LSPService {
3144
5065
  return null;
3145
5066
  return getter();
3146
5067
  }
3147
- const first = this.state.clients.values().next().value;
5068
+ const first = this.selectWorkspaceScopeClient();
3148
5069
  if (!first)
3149
5070
  return null;
3150
- const getter = first.getWorkspaceDiagnosticsSupport;
5071
+ const getter = first.client.getWorkspaceDiagnosticsSupport;
3151
5072
  if (typeof getter !== "function")
3152
5073
  return null;
3153
- return getter();
5074
+ if (attribution)
5075
+ attribution.getWorkspaceDiagnosticsSupport = first.serverId;
5076
+ return getter.call(first.client);
3154
5077
  }
3155
5078
  /**
3156
5079
  * Navigation: available code actions at position/range
@@ -3159,6 +5082,9 @@ export class LSPService {
3159
5082
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
3160
5083
  if (!spawned)
3161
5084
  return [];
5085
+ if (!spawned.client.getOperationSupport().codeAction) {
5086
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for codeAction");
5087
+ }
3162
5088
  return spawned.client.codeAction(filePath, line, character, endLine, endCharacter);
3163
5089
  }
3164
5090
  /**
@@ -3168,6 +5094,9 @@ export class LSPService {
3168
5094
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
3169
5095
  if (!spawned)
3170
5096
  return null;
5097
+ if (!spawned.client.getOperationSupport().rename) {
5098
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for rename");
5099
+ }
3171
5100
  return spawned.client.rename(filePath, line, character, newName);
3172
5101
  }
3173
5102
  async renameFile(oldFilePath, newFilePath, options) {
@@ -3252,16 +5181,16 @@ export class LSPService {
3252
5181
  }));
3253
5182
  const closeFailures = [];
3254
5183
  await Promise.all(openDocuments.map(async ({ serverId, client }) => {
3255
- try {
3256
- await client.closeDocument(oldFilePath);
3257
- return undefined;
3258
- }
3259
- catch (err) {
5184
+ // #1621: bounded so one wedged server's didClose write cannot stall
5185
+ // this Promise.all — and therefore the whole rename — for every
5186
+ // other client alongside it.
5187
+ const result = await runRenameNotify(() => client.closeDocument(oldFilePath), RENAME_NOTIFY_TIMEOUT_MS);
5188
+ if (!result.ok) {
3260
5189
  closeFailures.push({
3261
5190
  serverId,
3262
- error: err instanceof Error ? err.message : String(err),
5191
+ error: result.error,
5192
+ disposition: result.disposition,
3263
5193
  });
3264
- return undefined;
3265
5194
  }
3266
5195
  }));
3267
5196
  if (closeFailures.length > 0) {
@@ -3280,8 +5209,49 @@ export class LSPService {
3280
5209
  // next genuine open (#1147 P3-7).
3281
5210
  const content = await fs.readFile(oldFilePath, "utf-8");
3282
5211
  const languageId = getLanguageId(oldFilePath) ?? "plaintext";
3283
- await Promise.all(openDocuments.map(({ client }) => client.notify.open(oldFilePath, content, languageId, true, true)));
3284
- throw new Error(`workspace/didClose failed; rename aborted: ${closeFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
5212
+ const resyncFailures = [];
5213
+ // #1621 F1: the resync write is the SAME class of notify as the didClose
5214
+ // it is repairing after — a pipe that is wedged for didClose is wedged
5215
+ // for every subsequent write on it too, so this bare await reintroduced
5216
+ // the exact unbounded primitive one Promise.all up. Bound it with the
5217
+ // same budget and record the disposition rather than let a wedged
5218
+ // resync silently move the hang here instead of removing it.
5219
+ await Promise.all(openDocuments.map(async ({ serverId, client }) => {
5220
+ const resyncResult = await runRenameNotify(() => client.notify.open(oldFilePath, content, languageId, true, true), RENAME_NOTIFY_TIMEOUT_MS);
5221
+ if (!resyncResult.ok) {
5222
+ resyncFailures.push({
5223
+ serverId,
5224
+ error: resyncResult.error,
5225
+ disposition: resyncResult.disposition,
5226
+ });
5227
+ }
5228
+ }));
5229
+ if (resyncFailures.length > 0) {
5230
+ // A resync failure is not swallowed: the affected client is left with
5231
+ // no open document at all until its next genuine open, so this is
5232
+ // logged for the same reason lsp_client_shutdown records a forced
5233
+ // teardown — a degraded resync must be countable from the log.
5234
+ logLatency({
5235
+ type: "phase",
5236
+ phase: "lsp_rename_resync_failed",
5237
+ filePath: oldFilePath,
5238
+ durationMs: 0,
5239
+ metadata: {
5240
+ failures: resyncFailures.map((failure) => ({
5241
+ serverId: failure.serverId,
5242
+ disposition: failure.disposition,
5243
+ error: failure.error,
5244
+ })),
5245
+ },
5246
+ });
5247
+ }
5248
+ const closeFailureSummary = closeFailures
5249
+ .map((failure) => `${failure.serverId} (${failure.disposition}): ${failure.error}`)
5250
+ .join("; ");
5251
+ const resyncFailureSummary = resyncFailures.length > 0
5252
+ ? ` (resync also failed: ${resyncFailures.map((failure) => `${failure.serverId} (${failure.disposition}): ${failure.error}`).join("; ")})`
5253
+ : "";
5254
+ throw new Error(`workspace/didClose failed; rename aborted: ${closeFailureSummary}${resyncFailureSummary}`);
3285
5255
  }
3286
5256
  let renameApplied;
3287
5257
  try {
@@ -3314,21 +5284,19 @@ export class LSPService {
3314
5284
  const renameDescription = `Renamed ${relOld} → ${relNew}`;
3315
5285
  await Promise.all(activeClients.map(async ({ serverId, client }) => {
3316
5286
  const opened = openDocuments.find((entry) => entry.serverId === serverId);
3317
- try {
3318
- if (opened?.oldUri) {
3319
- await client.didRenameFiles(oldFilePath, newFilePath, opened.oldUri, destinationUriPreservingSpelling(opened.oldUri, oldFilePath, newFilePath));
3320
- }
3321
- else {
3322
- await client.didRenameFiles(oldFilePath, newFilePath);
3323
- }
3324
- return undefined;
3325
- }
3326
- catch (err) {
5287
+ // #1621: bounded for the same reason as the didClose notify above —
5288
+ // rename propagation is best-effort advice to servers, not a
5289
+ // correctness gate, so a wedged client's notify must not stall the
5290
+ // healthy clients settling alongside it in this Promise.all.
5291
+ const result = await runRenameNotify(() => opened?.oldUri
5292
+ ? client.didRenameFiles(oldFilePath, newFilePath, opened.oldUri, destinationUriPreservingSpelling(opened.oldUri, oldFilePath, newFilePath))
5293
+ : client.didRenameFiles(oldFilePath, newFilePath), RENAME_NOTIFY_TIMEOUT_MS);
5294
+ if (!result.ok) {
3327
5295
  didRenameFailures.push({
3328
5296
  serverId,
3329
- error: err instanceof Error ? err.message : String(err),
5297
+ error: result.error,
5298
+ disposition: result.disposition,
3330
5299
  });
3331
- return undefined;
3332
5300
  }
3333
5301
  }));
3334
5302
  const files = [...new Set([...applied.files, oldFilePath, newFilePath])];
@@ -3378,6 +5346,9 @@ export class LSPService {
3378
5346
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
3379
5347
  if (!spawned)
3380
5348
  return [];
5349
+ if (!spawned.client.getOperationSupport().implementation) {
5350
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for implementation");
5351
+ }
3381
5352
  return spawned.client.implementation(filePath, line, character);
3382
5353
  }
3383
5354
  /**
@@ -3387,24 +5358,38 @@ export class LSPService {
3387
5358
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
3388
5359
  if (!spawned)
3389
5360
  return [];
5361
+ if (!spawned.client.getOperationSupport().callHierarchy) {
5362
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for prepareCallHierarchy");
5363
+ }
3390
5364
  return spawned.client.prepareCallHierarchy(filePath, line, character);
3391
5365
  }
3392
5366
  /**
3393
5367
  * Navigation: find incoming calls (callers)
5368
+ *
5369
+ * #1803: gated on the target server's advertised `callHierarchyProvider`
5370
+ * (the same `getOperationSupport().callHierarchy` single source of truth
5371
+ * populated by `detectOperationSupport` in clients/lsp/client.ts — see
5372
+ * client.ts:5253). Mirrors the #1789 gate on `workspaceSymbol` above.
3394
5373
  */
3395
5374
  async incomingCalls(item) {
3396
5375
  const spawned = await this.getClientForFile(uriToPath(item.uri), NAV_CLIENT_WAIT_TIMEOUT_MS);
3397
5376
  if (!spawned)
3398
5377
  return [];
5378
+ if (!spawned.client.getOperationSupport().callHierarchy)
5379
+ return [];
3399
5380
  return spawned.client.incomingCalls(item);
3400
5381
  }
3401
5382
  /**
3402
5383
  * Navigation: find outgoing calls (callees)
5384
+ *
5385
+ * #1803: same gate as `incomingCalls` above.
3403
5386
  */
3404
5387
  async outgoingCalls(item) {
3405
5388
  const spawned = await this.getClientForFile(uriToPath(item.uri), NAV_CLIENT_WAIT_TIMEOUT_MS);
3406
5389
  if (!spawned)
3407
5390
  return [];
5391
+ if (!spawned.client.getOperationSupport().callHierarchy)
5392
+ return [];
3408
5393
  return spawned.client.outgoingCalls(item);
3409
5394
  }
3410
5395
  /**
@@ -3468,10 +5453,13 @@ export class LSPService {
3468
5453
  * attempts never proved it can answer diagnostics. Warm-up stays
3469
5454
  * `clientScope:"primary"` (not the sweep's `"all"`) on purpose: `"all"`
3470
5455
  * would additionally spawn the sweep-EXCLUDED auxiliaries
3471
- * (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`), and because `touchFile`'s
3472
- * `inconclusive` flag is touch-wide, one slow advisory auxiliary would then
3473
- * suppress the `demonstratedReady` marking for a perfectly healthy primary —
3474
- * falsely condemning it. Recording per-primary-server outcomes and skipping
5456
+ * (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`), which is work this warm-up does not
5457
+ * need. (Before #1549 it was also unsafe: `touchFile`'s `inconclusive` flag was
5458
+ * touch-wide, so one slow advisory auxiliary suppressed the `demonstratedReady`
5459
+ * marking for a perfectly healthy primary — falsely condemning it. The verdict is
5460
+ * per-server now, and the marking loop skips only the servers that did not answer
5461
+ * for themselves, so the scope choice is a cost argument rather than a
5462
+ * correctness one.) Recording per-primary-server outcomes and skipping
3475
5463
  * on those is the correct, non-regressing way to cover the servers the sweep
3476
5464
  * actually gates on (the sweep groups by primary server, so the group this
3477
5465
  * warms IS the one whose per-file touches would drag).
@@ -3664,9 +5652,30 @@ export class LSPService {
3664
5652
  * This is intentionally expensive and used only by explicit project-wide tools.
3665
5653
  */
3666
5654
  async runWorkspaceDiagnostics(cwd, options = {}) {
5655
+ // #1618: hold the shared sweep gate for this call's ENTIRE lifetime —
5656
+ // counter-based (not a boolean) so two overlapping `mode=full` calls each
5657
+ // release independently, and try/finally so a throw or an aborted sweep
5658
+ // still releases it (a leaked hold would permanently disable idle reset,
5659
+ // the inverse defect). While held, `clients/runtime-turn.ts`'s idle-reset
5660
+ // timer defers instead of destroying this service mid-sweep.
5661
+ const releaseSweepHold = acquireWorkspaceSweepHold();
5662
+ try {
5663
+ return await this.runWorkspaceDiagnosticsSwept(cwd, options);
5664
+ }
5665
+ finally {
5666
+ releaseSweepHold();
5667
+ }
5668
+ }
5669
+ async runWorkspaceDiagnosticsSwept(cwd, options = {}) {
3667
5670
  const startedAt = Date.now();
3668
5671
  const root = path.resolve(cwd);
3669
5672
  const { signal } = options;
5673
+ // #1783: second heartbeat for the drift backstop. The per-file touch
5674
+ // below already carries one, but a round that answers every file from
5675
+ // the #671 cache never calls touchFile at all — and that is precisely the
5676
+ // round whose stale answers the pi-codec witness recorded. Same rate
5677
+ // limit, same non-awaited contract.
5678
+ void this.sweepDocumentDrift().catch(() => { });
3670
5679
  // Cap the per-file LSP sweep: a Next.js-scale project can route thousands
3671
5680
  // of files through the language server at concurrency 8, and without a
3672
5681
  // caller cap that grinds for tens of minutes (#341). `maxFiles` lets
@@ -3742,6 +5751,16 @@ export class LSPService {
3742
5751
  if (cachedResults.length > 0) {
3743
5752
  options.onProgress?.(completed, files.length);
3744
5753
  }
5754
+ // #1782: which files this sweep is REPLAYING from cache, and which of those
5755
+ // a workspace pull then explicitly re-answered clean. A project-wide pull
5756
+ // report names files far beyond the group that asked for it; an explicit
5757
+ // zero-diagnostic answer for a replayed file is authoritative and must
5758
+ // supersede the replay, in the sweep's result list AND on disk. Without
5759
+ // this, a server that re-checked a file and found it clean could not
5760
+ // dislodge the stale entry by any means available to a user — the
5761
+ // 2026-08-20 dogfood's 23:07 record.
5762
+ const cacheServedKeys = new Set(cachedResults.map((result) => normalizeMapKey(result.filePath)));
5763
+ const supersededCacheKeys = new Set();
3745
5764
  // Per-file scan mtime captured as each file completes below, so a
3746
5765
  // confirmed fresh result can be written back into the cache with the
3747
5766
  // mtime it was ACTUALLY scanned at (not re-stat'd after the fact, which
@@ -3858,8 +5877,27 @@ export class LSPService {
3858
5877
  const preOpenAttempt = withDeadline((async () => {
3859
5878
  const { clients } = await this.getClientsForFile(filePath, WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS);
3860
5879
  for (const entry of clients) {
5880
+ // #1714: this pass is the sweep's SECOND source of `didOpen`
5881
+ // volume, and it reaches the server without passing the drain
5882
+ // barrier. Charge it to the same backlog ledger, and leave a
5883
+ // scanner that is already at its ceiling out of the burst.
5884
+ let auxKey;
5885
+ if (entry.info.role === "auxiliary") {
5886
+ auxKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
5887
+ if (auxKey && this.auxNotifyBacklogAtCeiling(auxKey, entry)) {
5888
+ continue;
5889
+ }
5890
+ }
3861
5891
  try {
3862
5892
  await entry.client.notify.open(filePath, content, languageId);
5893
+ if (auxKey)
5894
+ this.noteAuxNotifyIssued(auxKey, entry.client);
5895
+ // #1783: deliberately NOT recorded for the drift backstop.
5896
+ // This pass can skip a scanner at its backlog ceiling, so its
5897
+ // coverage is partial by design, and `processFile` runs
5898
+ // `touchFile` over every file in the group immediately after
5899
+ // — that call records with a full-coverage check. Stamping
5900
+ // here would only claim a coverage this loop does not have.
3863
5901
  }
3864
5902
  catch {
3865
5903
  // Best-effort: a failed pre-open just means processFile's own
@@ -3883,6 +5921,26 @@ export class LSPService {
3883
5921
  : preOpenAttempt);
3884
5922
  }
3885
5923
  };
5924
+ // #1618: a service destroyed mid-sweep (idle-reset race, an explicit
5925
+ // `resetLSPService` call, session replacement, …) must stop the loop for
5926
+ // every file it has not yet reached and record WHY — never a bare
5927
+ // `timedOut` that reads identically to a budget timeout. Cheap: no
5928
+ // language-server round trip, just a results push.
5929
+ const markServiceDestroyed = (remainingFiles) => {
5930
+ for (const filePath of remainingFiles) {
5931
+ results.push({
5932
+ filePath,
5933
+ diagnostics: [],
5934
+ count: 0,
5935
+ timedOut: true,
5936
+ unconfirmedReason: "service_destroyed",
5937
+ writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
5938
+ });
5939
+ timedOutFiles += 1;
5940
+ completed += 1;
5941
+ }
5942
+ options.onProgress?.(completed, files.length);
5943
+ };
3886
5944
  const processFile = async (filePath) => {
3887
5945
  try {
3888
5946
  const content = contentCache.get(filePath) ??
@@ -3954,20 +6012,37 @@ export class LSPService {
3954
6012
  // `perFileMs` deadline, which only catches a touch that never returned at
3955
6013
  // all within budget. Either one means the result wasn't confirmed.
3956
6014
  const inconclusive = touchResult?.inconclusive === true;
3957
- // #1470: a cut-off auxiliary is the THIRD reason this result is not a
3958
- // confirmed observation, and it is deliberately not `inconclusive`. The
6015
+ // #1470/#1493: an auxiliary that never reported — cut off by the grace
6016
+ // timer, or silent with nothing published for this content — is the
6017
+ // THIRD reason this result is not a confirmed observation, and it is
6018
+ // deliberately not `inconclusive`. The
3959
6019
  // record loop below persists every `!timedOut` result into the workspace
3960
6020
  // cache, so reading `inconclusive` alone caches a partially covered
3961
- // answer as clean and replays it on every later sweep. Today the only
3962
- // route that can reach this branch with a gap is the warm-attach
3963
- // incumbent, whose touch runs `clientScope: "with-auxiliary"`; the
3964
- // sweep's own local touch uses `clientScope: "all"`, which never arms
3965
- // the aux grace timer at all. Both are gated here so a future scope
3966
- // change cannot reopen the hole silently.
3967
- const coverageGap = touchCoverageGap(touchResult).length > 0;
3968
- const timedOut = touchResult === undefined || inconclusive || coverageGap;
6021
+ // answer as clean and replays it on every later sweep. The warm-attach
6022
+ // incumbent reaches this branch through the aux GRACE wait (its touch
6023
+ // runs `clientScope: "with-auxiliary"`), where all three no-answer shapes
6024
+ // arise. #1459 added the two pre-wait doors for the sweep's own local
6025
+ // `clientScope: "all"` touch — a scanner whose breaker was open, or whose
6026
+ // resync the fan-out gate deferred. #1533 closed the last hole: `"all"`
6027
+ // still never enters the grace wait, but it now derives the SAME evidence
6028
+ // from post-wait state, so a silent auxiliary narrows this scope too
6029
+ // instead of aggregating as a confirmed clean. Every route is gated here.
6030
+ const unconfirmedServerIds = touchCoverageGap(touchResult);
6031
+ const coverageGap = unconfirmedServerIds.length > 0;
6032
+ // #1549: the sweep verdict is per answering lane. An auxiliary gap
6033
+ // narrows coverage, but a primary answer remains usable; only absence of
6034
+ // the touch result or a primary-scoped inconclusive verdict poisons it.
6035
+ const timedOut = touchResult === undefined || inconclusive;
3969
6036
  if (timedOut)
3970
6037
  timedOutFiles += 1;
6038
+ // #1618: WHY, in priority order — the outer deadline (nothing came
6039
+ // back at all) outranks an inner inconclusive signal, which outranks
6040
+ // a narrower auxiliary coverage gap.
6041
+ const unconfirmedReason = touchResult === undefined
6042
+ ? "budget"
6043
+ : inconclusive
6044
+ ? "inconclusive"
6045
+ : undefined;
3971
6046
  // #1104 (shape 5 — AGENTS.md): the touch's content binding is an
3972
6047
  // EXPLICIT enumerable field on the wrapper now (#1179), so it survives
3973
6048
  // `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
@@ -3997,7 +6072,10 @@ export class LSPService {
3997
6072
  filePath,
3998
6073
  diagnostics: filteredDiagnostics ?? [],
3999
6074
  count: filteredDiagnostics?.length ?? 0,
4000
- timedOut,
6075
+ ...(timedOut && { timedOut: true, unconfirmedReason }),
6076
+ ...(coverageGap && {
6077
+ unconfirmedServerIds: [...unconfirmedServerIds],
6078
+ }),
4001
6079
  contentHash: rawBinding?.contentHash,
4002
6080
  boundToCurrentDisk: rawBinding?.boundToCurrentDisk,
4003
6081
  writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
@@ -4011,8 +6089,12 @@ export class LSPService {
4011
6089
  error: err instanceof Error ? err.message : String(err),
4012
6090
  // An errored check is exactly as inconclusive as a timed-out one —
4013
6091
  // no confirmed result was obtained, so reconciliation (#571) must
4014
- // skip it the same way.
6092
+ // skip it the same way. #1618: distinct from every OTHER
6093
+ // `timedOut: true` reason — an `error` must never render as "didn't
6094
+ // complete within budget" (the old dead-subtraction bug in
6095
+ // tools/lens-diagnostics.ts's `unconfirmedErrored` computation).
4015
6096
  timedOut: true,
6097
+ unconfirmedReason: "error",
4016
6098
  writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
4017
6099
  });
4018
6100
  }
@@ -4046,13 +6128,29 @@ export class LSPService {
4046
6128
  await runPerServerGroups(groups, groupWorkers, async (group) => {
4047
6129
  if (signal?.aborted)
4048
6130
  return;
6131
+ // #1618: checked before any per-group work (pull attempt, warm-up,
6132
+ // per-file loop) so a service already destroyed when this group
6133
+ // starts never pays for a language-server round trip it cannot get.
6134
+ if (this.checkDestroyed()) {
6135
+ markServiceDestroyed(group.files);
6136
+ return;
6137
+ }
4049
6138
  // Fast path: one project-wide pull for the whole group (opt-in).
4050
6139
  if (!isWarmAttached() &&
4051
6140
  workspacePullEnabled &&
4052
6141
  !group.multiServer) {
4053
- const pulled = await this.tryWorkspacePull(group.files, perFileMs);
6142
+ const pulled = await this.tryWorkspacePull(group.files, perFileMs, cacheServedKeys);
4054
6143
  if (pulled) {
4055
- for (const result of pulled) {
6144
+ // #1782: an explicit zero-diagnostic answer for a file this
6145
+ // sweep served from cache SUPERSEDES that cached replay. Route
6146
+ // it through the same result list every other answer uses, so
6147
+ // the cache write below overwrites the stale entry and the
6148
+ // footer reconcile in `tools/lens-diagnostics.ts` clears the
6149
+ // widget rows — no second eviction path to keep in step.
6150
+ for (const clean of pulled.extraClean) {
6151
+ supersededCacheKeys.add(normalizeMapKey(clean.filePath));
6152
+ }
6153
+ for (const result of [...pulled.results, ...pulled.extraClean]) {
4056
6154
  results.push({
4057
6155
  ...result,
4058
6156
  writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
@@ -4117,6 +6215,7 @@ export class LSPService {
4117
6215
  diagnostics: [],
4118
6216
  count: 0,
4119
6217
  timedOut: true,
6218
+ unconfirmedReason: "inconclusive",
4120
6219
  skippedWarmupFailure: true,
4121
6220
  });
4122
6221
  timedOutFiles += 1;
@@ -4138,6 +6237,15 @@ export class LSPService {
4138
6237
  for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
4139
6238
  if (signal?.aborted)
4140
6239
  return;
6240
+ // #1618: a service destroyed WHILE this group's chunk loop was
6241
+ // already running (the group-start check above can't see a
6242
+ // destruction that lands mid-loop) — stop here and mark every
6243
+ // file from this chunk onward, rather than letting the remaining
6244
+ // chunks pay for pre-opens/touches against a torn-down service.
6245
+ if (this.checkDestroyed()) {
6246
+ markServiceDestroyed(group.files.slice(chunkStart));
6247
+ return;
6248
+ }
4141
6249
  const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
4142
6250
  await preOpenGroupFiles(chunk);
4143
6251
  for (const filePath of chunk) {
@@ -4145,10 +6253,33 @@ export class LSPService {
4145
6253
  // results are returned as a partial.
4146
6254
  if (signal?.aborted)
4147
6255
  return;
6256
+ if (this.checkDestroyed()) {
6257
+ markServiceDestroyed(group.files.slice(group.files.indexOf(filePath)));
6258
+ return;
6259
+ }
4148
6260
  await processFile(filePath);
4149
6261
  }
4150
6262
  }
4151
6263
  }, signal);
6264
+ // #1618: per-reason tally alongside the flat `timedOutFiles` count (kept
6265
+ // for the existing `scripts/analyze-pi-lens-logs.mjs` consumer) — a
6266
+ // dashboard reading only the flat count can no longer mistake 81
6267
+ // service-destroyed files for 81 budget-exhausted ones.
6268
+ const unconfirmedByReason = {};
6269
+ for (const result of results) {
6270
+ if (!result.timedOut)
6271
+ continue;
6272
+ const reason = result.unconfirmedReason ?? "budget";
6273
+ unconfirmedByReason[reason] = (unconfirmedByReason[reason] ?? 0) + 1;
6274
+ }
6275
+ const partiallyCoveredFiles = results.filter((result) => (result.unconfirmedServerIds?.length ?? 0) > 0).length;
6276
+ // Code-unit comparator (#1883): this list ships as the
6277
+ // `unconfirmedServerIds` field on the `lsp_workspace_diagnostics` record,
6278
+ // so its order must be deterministic across locales — localeCompare is
6279
+ // deliberately avoided.
6280
+ const unconfirmedServerIds = [
6281
+ ...new Set(results.flatMap((result) => result.unconfirmedServerIds ?? [])),
6282
+ ].sort((a, b) => Number(a > b) - Number(a < b));
4152
6283
  logLatency({
4153
6284
  type: "phase",
4154
6285
  phase: "lsp_workspace_diagnostics",
@@ -4162,6 +6293,9 @@ export class LSPService {
4162
6293
  concurrency: groupWorkers,
4163
6294
  maxFiles,
4164
6295
  timedOutFiles,
6296
+ unconfirmedByReason,
6297
+ partiallyCoveredFiles,
6298
+ ...(unconfirmedServerIds.length > 0 && { unconfirmedServerIds }),
4165
6299
  aborted: signal?.aborted ?? false,
4166
6300
  },
4167
6301
  });
@@ -4178,8 +6312,12 @@ export class LSPService {
4178
6312
  // freshness check; nothing here needs to explicitly evict them).
4179
6313
  for (const result of results) {
4180
6314
  const scannedAt = scannedMtimeByFile.get(result.filePath);
4181
- if (result.error || result.timedOut || scannedAt === undefined)
6315
+ if (result.error ||
6316
+ result.timedOut ||
6317
+ (result.unconfirmedServerIds?.length ?? 0) > 0 ||
6318
+ scannedAt === undefined) {
4182
6319
  continue;
6320
+ }
4183
6321
  // #1104: thread the per-result `contentHash` (from either the
4184
6322
  // `tryWorkspacePull` fast path or a per-file touch's own #1095 binding)
4185
6323
  // into the cache entry — previously this call never passed one, so
@@ -4191,15 +6329,37 @@ export class LSPService {
4191
6329
  workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
4192
6330
  }
4193
6331
  workspaceDiagnosticsCacheCtx.persist();
4194
- return [...cachedResults, ...results].filter(Boolean);
6332
+ // #1782: drop every cached replay a pull answered clean — returning both
6333
+ // would hand the footer reconcile two contradictory results for one file,
6334
+ // and the stale one could win on ordering.
6335
+ const servedCacheResults = supersededCacheKeys.size === 0
6336
+ ? cachedResults
6337
+ : cachedResults.filter((result) => !supersededCacheKeys.has(normalizeMapKey(result.filePath)));
6338
+ return [...servedCacheResults, ...results].filter(Boolean);
4195
6339
  }
4196
6340
  /**
4197
6341
  * #387 Item 2: one `workspace/diagnostic` pull covering a whole server group,
4198
6342
  * instead of N per-file opens. Returns per-file results (files absent from the
4199
6343
  * report are reported clean), or `undefined` when the server doesn't advertise
4200
6344
  * workspace pull / the pull fails — the caller then falls back to per-file.
6345
+ *
6346
+ * #1782: the report is project-wide, so it routinely names files this group
6347
+ * never asked about — including files this sweep already served from the
6348
+ * cache. Those answers used to be dropped on the floor: the mapping below
6349
+ * only ever looked up `groupFiles`. That is how a server can explicitly
6350
+ * re-answer a file with ZERO diagnostics while the cache and the widget keep
6351
+ * rendering its stale blockers, which is what the 2026-08-20 dogfood recorded
6352
+ * at 23:07. `reanswerFor` opts a caller into those extra answers: pass the
6353
+ * normalized keys of files served from cache, and any of them the report
6354
+ * explicitly names with zero diagnostics comes back in `extraClean` as a
6355
+ * confirmed clean result.
6356
+ *
6357
+ * Only an EXPLICIT zero-diagnostic entry qualifies. Absence from the report
6358
+ * reads as clean for `groupFiles`, which this sweep did ask about, but it is
6359
+ * genuinely UNKNOWN for a file nobody asked about — a server may report only
6360
+ * what it re-checked.
4201
6361
  */
4202
- async tryWorkspacePull(groupFiles, perFileMs) {
6362
+ async tryWorkspacePull(groupFiles, perFileMs, reanswerFor) {
4203
6363
  try {
4204
6364
  const first = groupFiles[0];
4205
6365
  if (!first)
@@ -4213,11 +6373,13 @@ export class LSPService {
4213
6373
  const report = await spawned.client.requestWorkspaceDiagnostics(Math.max(perFileMs, workspacePullBudgetMs()));
4214
6374
  if (!report)
4215
6375
  return undefined;
6376
+ // Last-wins per file: the report builder does not dedup, so a server
6377
+ // naming the same URI twice appears twice in `report` (#1786 review F2).
4216
6378
  const byPath = new Map();
4217
6379
  for (const entry of report) {
4218
6380
  byPath.set(normalizeMapKey(entry.filePath), entry);
4219
6381
  }
4220
- return groupFiles.map((filePath) => {
6382
+ const results = groupFiles.map((filePath) => {
4221
6383
  const entry = byPath.get(normalizeMapKey(filePath));
4222
6384
  const diagnostics = entry?.diagnostics ?? [];
4223
6385
  // A pull that got here returned a real workspace/diagnostic report
@@ -4234,6 +6396,48 @@ export class LSPService {
4234
6396
  contentHash: entry?.contentHash,
4235
6397
  };
4236
6398
  });
6399
+ // #1782: harvest explicit clean answers for files this group never asked
6400
+ // about but the caller is serving from cache. Same confirmed status as
6401
+ // the mapping above — it is the same report.
6402
+ //
6403
+ // #1786 review F2: iterate `byPath`, not `report`. The report BUILDER
6404
+ // (`clients/lsp/client.ts`'s `requestWorkspaceDiagnostics`) pushes one
6405
+ // output entry per report item with no dedup, so a server that names the
6406
+ // same URI twice yields two entries for one file. Walking the raw list
6407
+ // would emit two results for one file — breaking the caller's
6408
+ // one-result-per-file invariant — and would let a zero-diagnostic
6409
+ // duplicate evict a cached blocker that the SAME report also reports as
6410
+ // still failing. `byPath` is last-wins and unique per file, and
6411
+ // `withFindings` refuses eviction whenever ANY entry for that file
6412
+ // reports findings, whatever the order. Refusing costs a stale entry one
6413
+ // more sweep; evicting on a contradicted answer discards a live blocker.
6414
+ const extraClean = [];
6415
+ if (reanswerFor && reanswerFor.size > 0) {
6416
+ const asked = new Set(groupFiles.map((f) => normalizeMapKey(f)));
6417
+ const withFindings = new Set();
6418
+ for (const entry of report) {
6419
+ if (entry.diagnostics.length > 0) {
6420
+ withFindings.add(normalizeMapKey(entry.filePath));
6421
+ }
6422
+ }
6423
+ for (const [key, entry] of byPath) {
6424
+ // The membership filter is load-bearing: a report names files far
6425
+ // beyond this sweep, and a file nobody asked about and nothing is
6426
+ // replaying has no business entering the sweep's results.
6427
+ if (asked.has(key) || !reanswerFor.has(key))
6428
+ continue;
6429
+ if (entry.diagnostics.length > 0 || withFindings.has(key))
6430
+ continue;
6431
+ extraClean.push({
6432
+ filePath: entry.filePath,
6433
+ diagnostics: [],
6434
+ count: 0,
6435
+ timedOut: false,
6436
+ contentHash: entry.contentHash,
6437
+ });
6438
+ }
6439
+ }
6440
+ return { results, extraClean };
4237
6441
  }
4238
6442
  catch {
4239
6443
  return undefined;
@@ -4317,6 +6521,35 @@ export class LSPService {
4317
6521
  supportsLSP(filePath) {
4318
6522
  return getServersForFileWithConfig(filePath).length > 0;
4319
6523
  }
6524
+ /**
6525
+ * Check whether the PRIMARY server for this file is currently mid-spawn
6526
+ * (`state.inFlight`, keyed `${server.id}:${normalizeMapKey(root)}` — see
6527
+ * :2413). Lets a caller whose own wait budget expires distinguish "the
6528
+ * server hasn't finished its first spawn yet" from "the server is running
6529
+ * but slow/wedged" (#1766) — a cold spawn still in flight is not a verdict
6530
+ * on a server that doesn't exist yet.
6531
+ *
6532
+ * Mirrors the `role !== "auxiliary"` filter getClientForFile applies at
6533
+ * :2146-2148 (kept coupled to that line intentionally): auxiliary servers
6534
+ * (opengrep, typos, …) spawn routinely and concurrently with an ALREADY
6535
+ * ALIVE primary (dispatch/runners/lsp.ts's with-auxiliary path fires one
6536
+ * per edit for most files via TYPOS_EXTENSIONS). Without this filter, an
6537
+ * unrelated auxiliary spawn would downgrade a genuinely wedged primary to
6538
+ * a benign "spawn-in-flight" verdict — the worse misreport direction.
6539
+ *
6540
+ * The touch path supplies roots resolved before acquisition. Matching the full
6541
+ * server/root key prevents another workspace's spawn from relabeling this one.
6542
+ * Root resolution is already complete, and this check stays synchronous.
6543
+ * The failure mode is limited to the matching workspace key.
6544
+ * Pure lookup — does not spawn or wait for a client.
6545
+ */
6546
+ isSpawnInFlight(_filePath, resolvedRoots = new Map()) {
6547
+ for (const [serverId, root] of resolvedRoots) {
6548
+ if (this.state.inFlight.has(`${serverId}:${root}`))
6549
+ return true;
6550
+ }
6551
+ return false;
6552
+ }
4320
6553
  /**
4321
6554
  * Check whether an LSP client is already alive for a file.
4322
6555
  * Lightweight — does not spawn or wait for a client.
@@ -4387,6 +6620,25 @@ export class LSPService {
4387
6620
  });
4388
6621
  this.state.clients.clear();
4389
6622
  this.state.broken.clear();
6623
+ // #1934 review F1: map hygiene alongside the breaker it sits next to.
6624
+ // Not load-bearing — every read follows its own attempt's write — but a
6625
+ // verdict for a client generation that no longer exists is dead weight.
6626
+ this.lastSpawnVerdict.clear();
6627
+ // #1459: every gated client is gone, so no outstanding-write record can
6628
+ // describe a live one. The gate's identity check already neutralises a stale
6629
+ // entry; clearing keeps the map honest rather than relying on that.
6630
+ this.outstandingAuxNotifyWrites.clear();
6631
+ // #1714: same reasoning — a backlog count belongs to a client generation,
6632
+ // and every client is gone. `session_start` reaches this through the service
6633
+ // reset, so the pacing state re-arms with the session rather than living for
6634
+ // the process.
6635
+ this.auxNotifyInflight.clear();
6636
+ // #1783: the drift records describe what THESE clients hold. Every client
6637
+ // is gone, so every record is a claim about a dead server's view. Keeping
6638
+ // them would make the first sweep of the next generation resync files no
6639
+ // one has open yet, and — like the pacing state above — this is state that
6640
+ // must re-arm at `session_start`, which reaches it through this reset.
6641
+ this.documentDrift.clear();
4390
6642
  this.workspaceProbeLogged.clear();
4391
6643
  this.warmStartLogged.clear();
4392
6644
  }
@@ -4542,7 +6794,27 @@ export function getLSPService() {
4542
6794
  export async function isAuxiliaryLspAlive(serverId, filePath) {
4543
6795
  return getLSPService().isServerAliveForFile(serverId, filePath);
4544
6796
  }
6797
+ /**
6798
+ * Cross-layer seam (#1668) for callers outside `lsp/` (bash/write tool-result
6799
+ * handling) that observe a disk change no open-document sync path will ever
6800
+ * report — an external delete/create/modify. Delivers to already-active
6801
+ * clients only; see `LSPService.notifyExternalFileChange`.
6802
+ */
6803
+ export async function notifyExternalFileChange(filePath, type) {
6804
+ return getLSPService().notifyExternalFileChange(filePath, type);
6805
+ }
4545
6806
  export function resetLSPService(options = {}) {
6807
+ // A new session must get its own classic-tsserver-repair attempt: the
6808
+ // guard is a process-lifetime flag (see resetClassicTsRepairGuard), so a
6809
+ // repair that failed transiently in an earlier session must not stay
6810
+ // latched for the rest of the extension-host process (#1570).
6811
+ if (options.reason === "session_start") {
6812
+ resetClassicTsRepairGuard();
6813
+ // #1618 (R3): a hold from a PRIOR generation's sweep must never survive
6814
+ // a session boundary — state that must re-arm at session_start cannot
6815
+ // hide behind a leaked/stuck guard from the generation before it.
6816
+ clearWorkspaceSweepHoldForSessionStart();
6817
+ }
4546
6818
  const retiringService = globalLSPService;
4547
6819
  globalLSPService = null;
4548
6820
  if (!retiringService)