@herbertgao/pi-extensions 2026.8.5 → 2026.8.7

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 (389) hide show
  1. package/README.md +5 -5
  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 +272 -22
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +48 -6
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +1 -1
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +14 -0
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +55 -17
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
  17. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  18. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +16 -0
  19. package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
  20. package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
  21. package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
  22. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
  23. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
  24. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +22 -15
  25. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
  26. package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
  27. package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
  28. package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
  29. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
  30. package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
  31. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
  32. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
  33. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +34 -10
  34. package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
  35. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +29 -5
  36. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +3 -0
  37. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +46 -10
  38. package/node_modules/@juicesharp/rpiv-ask-user-question/config.ts +25 -0
  39. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/configuration.md +13 -4
  40. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +8 -1
  41. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +3 -1
  42. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +2 -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 +3 -1
  47. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +30 -7
  48. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
  49. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +30 -4
  50. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +26 -5
  51. package/node_modules/@narumitw/pi-btw/README.md +119 -103
  52. package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
  53. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
  54. package/node_modules/@narumitw/pi-btw/package.json +13 -9
  55. package/node_modules/@narumitw/pi-btw/src/btw.ts +40 -42
  56. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
  57. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
  58. package/node_modules/@narumitw/pi-btw/src/menu.ts +78 -15
  59. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  60. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
  61. package/node_modules/pi-lens/CHANGELOG.md +1004 -0
  62. package/node_modules/pi-lens/README.md +11 -3
  63. package/node_modules/pi-lens/config/ruff/core.toml +42 -0
  64. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
  65. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +427 -0
  66. package/node_modules/pi-lens/dist/clients/agent-nudge.js +79 -16
  67. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
  68. package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
  69. package/node_modules/pi-lens/dist/clients/bash-file-access.js +285 -8
  70. package/node_modules/pi-lens/dist/clients/biome-client.js +176 -37
  71. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
  72. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +119 -0
  73. package/node_modules/pi-lens/dist/clients/bootstrap.js +12 -1
  74. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +12 -3
  75. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +192 -0
  76. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  77. package/node_modules/pi-lens/dist/clients/bus-publish.js +11 -3
  78. package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
  79. package/node_modules/pi-lens/dist/clients/cascade-format.js +76 -2
  80. package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
  81. package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
  82. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
  83. package/node_modules/pi-lens/dist/clients/console-guard-install.js +16 -4
  84. package/node_modules/pi-lens/dist/clients/dead-code-client.js +249 -52
  85. package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
  86. package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
  87. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +62 -13
  88. package/node_modules/pi-lens/dist/clients/dependency-checker.js +43 -9
  89. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +153 -37
  90. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
  91. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  92. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +165 -11
  93. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +325 -72
  94. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
  95. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +11 -3
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +12 -7
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +18 -0
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +81 -41
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -0
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +23 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +331 -55
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +38 -5
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +97 -19
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +14 -0
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +20 -0
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +22 -0
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +10 -0
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +18 -6
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +488 -0
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +84 -0
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
  122. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +943 -93
  123. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
  124. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +72 -0
  125. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +106 -0
  126. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +9 -0
  127. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -1
  128. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
  129. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  130. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  131. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
  132. package/node_modules/pi-lens/dist/clients/extension-log.js +317 -3
  133. package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
  134. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +294 -0
  135. package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
  136. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  137. package/node_modules/pi-lens/dist/clients/format-events-publish.js +39 -8
  138. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
  139. package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
  140. package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
  141. package/node_modules/pi-lens/dist/clients/git-guard.js +49 -21
  142. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
  143. package/node_modules/pi-lens/dist/clients/go-client.js +21 -39
  144. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +219 -10
  145. package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
  146. package/node_modules/pi-lens/dist/clients/host-ports.js +9 -1
  147. package/node_modules/pi-lens/dist/clients/installer/index.js +1119 -101
  148. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
  149. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
  150. package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
  151. package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
  152. package/node_modules/pi-lens/dist/clients/jscpd-client.js +86 -15
  153. package/node_modules/pi-lens/dist/clients/knip-client.js +361 -24
  154. package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
  155. package/node_modules/pi-lens/dist/clients/language-profile.js +2 -0
  156. package/node_modules/pi-lens/dist/clients/latency-logger.js +487 -9
  157. package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
  158. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
  159. package/node_modules/pi-lens/dist/clients/lens-events.js +56 -25
  160. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
  161. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +45 -2
  162. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  163. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +160 -20
  164. package/node_modules/pi-lens/dist/clients/lsp/client.js +1666 -148
  165. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +111 -0
  166. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
  167. package/node_modules/pi-lens/dist/clients/lsp/index.js +2496 -171
  168. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
  169. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
  170. package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
  171. package/node_modules/pi-lens/dist/clients/lsp/server.js +517 -69
  172. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
  173. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
  174. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +172 -0
  175. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  176. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +61 -3
  177. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
  178. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
  179. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
  180. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
  181. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +4 -0
  182. package/node_modules/pi-lens/dist/clients/mcp/session.js +39 -18
  183. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  184. package/node_modules/pi-lens/dist/clients/module-report.js +8 -0
  185. package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
  186. package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
  187. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
  188. package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
  189. package/node_modules/pi-lens/dist/clients/pipeline.js +178 -30
  190. package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
  191. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
  192. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
  193. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +85 -15
  194. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
  195. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  196. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
  197. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +5 -0
  198. package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
  199. package/node_modules/pi-lens/dist/clients/project-snapshot.js +299 -0
  200. package/node_modules/pi-lens/dist/clients/quiet-window-config.js +47 -0
  201. package/node_modules/pi-lens/dist/clients/quiet-window.js +11 -25
  202. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +1 -1
  203. package/node_modules/pi-lens/dist/clients/read-guard.js +184 -10
  204. package/node_modules/pi-lens/dist/clients/resource-sampler.js +49 -17
  205. package/node_modules/pi-lens/dist/clients/reverse-deps.js +50 -11
  206. package/node_modules/pi-lens/dist/clients/review-graph/query.js +76 -4
  207. package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
  208. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +248 -13
  209. package/node_modules/pi-lens/dist/clients/runtime-context.js +112 -12
  210. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +472 -61
  211. package/node_modules/pi-lens/dist/clients/runtime-session.js +359 -13
  212. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
  213. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +269 -22
  214. package/node_modules/pi-lens/dist/clients/runtime-turn.js +948 -87
  215. package/node_modules/pi-lens/dist/clients/rust-client.js +21 -37
  216. package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
  217. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
  218. package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
  219. package/node_modules/pi-lens/dist/clients/security-scan-client.js +224 -8
  220. package/node_modules/pi-lens/dist/clients/sg-runner.js +249 -37
  221. package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
  222. package/node_modules/pi-lens/dist/clients/smells-rollup.js +18 -11
  223. package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
  224. package/node_modules/pi-lens/dist/clients/startup-timing.js +26 -2
  225. package/node_modules/pi-lens/dist/clients/test-runner-client.js +574 -32
  226. package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
  227. package/node_modules/pi-lens/dist/clients/tool-policy.js +127 -20
  228. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  229. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
  230. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
  231. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
  232. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
  233. package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
  234. package/node_modules/pi-lens/dist/clients/warm-attach.js +17 -0
  235. package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
  236. package/node_modules/pi-lens/dist/clients/word-index.js +305 -33
  237. package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
  238. package/node_modules/pi-lens/dist/index.js +64677 -49484
  239. package/node_modules/pi-lens/dist/mcp/server.js +8 -2
  240. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  241. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  242. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  243. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +469 -56
  244. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +190 -46
  245. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
  246. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  247. package/node_modules/pi-lens/docs/agent-guide.md +44 -14
  248. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +518 -501
  249. package/node_modules/pi-lens/docs/dispositions.md +21 -0
  250. package/node_modules/pi-lens/docs/features.md +33 -6
  251. package/node_modules/pi-lens/docs/globalconfig.md +14 -0
  252. package/node_modules/pi-lens/docs/language-coverage.md +71 -5
  253. package/node_modules/pi-lens/docs/mcp.md +2 -2
  254. package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
  255. package/node_modules/pi-lens/docs/settings.md +25 -1
  256. package/node_modules/pi-lens/docs/usage.md +24 -5
  257. package/node_modules/pi-lens/docs/word-index.md +35 -0
  258. package/node_modules/pi-lens/package.json +10 -6
  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 +22 -0
  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-conditional-empty-object-spread-js-test.yml +9 -0
  266. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  267. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
  268. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
  269. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
  270. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
  271. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
  272. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +9 -0
  273. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +9 -0
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +9 -0
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +9 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
  288. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
  289. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
  290. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
  291. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
  292. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
  293. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
  294. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +66 -0
  295. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
  296. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  297. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  298. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
  299. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
  300. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
  301. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
  302. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
  303. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
  304. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +29 -0
  305. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +30 -0
  306. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +41 -0
  307. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +42 -0
  308. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
  309. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
  310. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
  311. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
  312. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
  313. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
  314. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +32 -0
  315. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
  316. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
  317. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
  318. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
  319. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
  320. package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
  321. package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
  322. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
  323. package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
  324. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +132 -1
  325. package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
  326. package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
  327. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +12 -11
  328. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  329. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +87 -116
  330. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +170 -0
  331. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  332. package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
  333. package/node_modules/pi-mcp-adapter/CHANGELOG.md +49 -0
  334. package/node_modules/pi-mcp-adapter/README.md +51 -6
  335. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  336. package/node_modules/pi-mcp-adapter/cli.js +188 -5
  337. package/node_modules/pi-mcp-adapter/commands.ts +128 -22
  338. package/node_modules/pi-mcp-adapter/config.ts +28 -10
  339. package/node_modules/pi-mcp-adapter/direct-tools.ts +23 -19
  340. package/node_modules/pi-mcp-adapter/index.ts +219 -32
  341. package/node_modules/pi-mcp-adapter/init.ts +36 -4
  342. package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
  343. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
  344. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
  345. package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
  346. package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
  347. package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
  348. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  349. package/node_modules/pi-mcp-adapter/metadata-cache.ts +46 -25
  350. package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
  351. package/node_modules/pi-mcp-adapter/package.json +4 -1
  352. package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
  353. package/node_modules/pi-mcp-adapter/request-headers-command.ts +330 -0
  354. package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
  355. package/node_modules/pi-mcp-adapter/server-manager.ts +145 -2
  356. package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
  357. package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
  358. package/node_modules/pi-mcp-adapter/tool-metadata.ts +38 -18
  359. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
  360. package/node_modules/pi-mcp-adapter/types.ts +95 -10
  361. package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
  362. package/node_modules/pi-mcp-adapter/utils.ts +70 -0
  363. package/node_modules/pi-web-access/CHANGELOG.md +37 -0
  364. package/node_modules/pi-web-access/README.md +38 -14
  365. package/node_modules/pi-web-access/anysearch.ts +4 -2
  366. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  367. package/node_modules/pi-web-access/brave.ts +16 -4
  368. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  369. package/node_modules/pi-web-access/curator-page.ts +8 -3
  370. package/node_modules/pi-web-access/curator-server.ts +5 -1
  371. package/node_modules/pi-web-access/exa.ts +18 -7
  372. package/node_modules/pi-web-access/extract.ts +134 -37
  373. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  374. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  375. package/node_modules/pi-web-access/gemini-search.ts +33 -7
  376. package/node_modules/pi-web-access/index.ts +160 -104
  377. package/node_modules/pi-web-access/package.json +2 -2
  378. package/node_modules/pi-web-access/page-query.ts +4 -2
  379. package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
  380. package/node_modules/pi-web-access/parallel.ts +2 -2
  381. package/node_modules/pi-web-access/query-rewrite.ts +51 -0
  382. package/node_modules/pi-web-access/serper.ts +211 -0
  383. package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
  384. package/node_modules/pi-web-access/summary-review.ts +51 -23
  385. package/node_modules/pi-web-access/tavily.ts +16 -4
  386. package/node_modules/pi-web-access/utils.ts +87 -0
  387. package/node_modules/pi-web-access/valyu.ts +199 -0
  388. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  389. package/package.json +13 -13
@@ -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 { isAtOrAboveHomeDir, normalizeMapKey, uriToPath, } from "../path-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";
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, } 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;
@@ -230,13 +281,15 @@ function readTsserverSyncGraceMs() {
230
281
  }
231
282
  /**
232
283
  * Read the `PI_LENS_AUX_GRACE_MS` env override at call time (not module
233
- * load time) so tests can set it per-case. Controls how long auxiliary-role
234
- * promises (opengrep, ast-grep, zizmor, …) are waited after all primary-role
235
- * promises have settled in both getDiagnostics (raceToCompletion) and the
236
- * touchFile push wait. Default 500ms — conservative enough to include
237
- * auxiliaries that are nearly done while not blocking the primary result.
238
- * Returns undefined when the var is absent (caller falls back to the
239
- * raceToCompletion default of 500ms, keeping the two in sync).
284
+ * load time) so tests can set it per-case. Controls the CEILING on how long
285
+ * auxiliary-role promises (opengrep, ast-grep, zizmor, …) are waited after
286
+ * all primary-role promises have settled, in both getDiagnostics
287
+ * (raceToCompletion) and the touchFile push wait (#1458 S2 — the two lanes
288
+ * share the same declared-budget-capped-by-ceiling shape). Each auxiliary
289
+ * still gets only its OWN declared `aggregateWaitMs` up to this ceiling —
290
+ * this is not a flat per-touch wait. Returns undefined when the var is
291
+ * absent; each call site then supplies its own default ceiling (touchFile:
292
+ * 2000ms; getDiagnostics: 2000ms — see the `?? 2000` at each call site).
240
293
  */
241
294
  function readEnvAuxGraceMs() {
242
295
  const raw = process.env.PI_LENS_AUX_GRACE_MS;
@@ -252,7 +305,6 @@ const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(0, Number.parseInt(process.
252
305
  // Once the fastest client has diagnostics, remaining clients get this window before
253
306
  // we proceed with whatever results are ready. 0 disables early-unblock.
254
307
  const EARLY_UNBLOCK_GRACE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_EARLY_UNBLOCK_GRACE_MS ?? "400", 10) || 400);
255
- const CASCADE_DIAGNOSTICS_TTL_MS = 240_000;
256
308
  function mergeLspDiagnostics(diagnostics) {
257
309
  const merged = [];
258
310
  const seen = new Set();
@@ -397,6 +449,40 @@ function notifyWriteBudgetMs() {
397
449
  const raw = Number(process.env.PI_LENS_LSP_NOTIFY_BUDGET_MS);
398
450
  return Number.isFinite(raw) && raw > 0 ? raw : 2000;
399
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
+ }
400
486
  // Budget for one project-wide `workspace/diagnostic` pull (#387 Item 2). Larger
401
487
  // than a per-file wait — it's a single request but scans the whole program —
402
488
  // yet bounded so a hung server still falls back to the per-file path.
@@ -545,6 +631,13 @@ export class LSPService {
545
631
  * those falls through to a fresh check rather than serving a stale result.
546
632
  */
547
633
  lastKnownContentHash = new Map();
634
+ /**
635
+ * #1783: what content actually landed on a language server, per document.
636
+ * The drift sweep compares disk (size, mtime) against these records and
637
+ * resynchronizes anything an untracked edit moved behind the server's back.
638
+ * See `document-drift.ts` for the key design and the pacing rules.
639
+ */
640
+ documentDrift = new DocumentDriftTracker();
548
641
  /**
549
642
  * #1095: lazily verifies a stored {@link DiagnosticBinding} against current
550
643
  * disk bytes, memoizing the disk fingerprint per (file, mtime) so repeated
@@ -575,6 +668,42 @@ export class LSPService {
575
668
  * successful write clears its entry.
576
669
  */
577
670
  notifyWriteBackpressureStreak = new Map();
671
+ /**
672
+ * #1714: unacknowledged auxiliary document notifies per server key
673
+ * ("serverId:normalizedRoot" — the same identity as every other gate here).
674
+ *
675
+ * `unacked` counts notifies ISSUED to this client that the server has not yet
676
+ * been proven to have PROCESSED. It rises with each write the sweep hands over
677
+ * and falls only when a drain barrier round-trips (see {@link paceAuxNotify}).
678
+ * `drain` holds the one in-flight barrier so a burst shares a single
679
+ * round-trip instead of each touch sending its own.
680
+ *
681
+ * `gateOpen` is the fail-open latch. A scanner that will not answer the
682
+ * barrier inside the caller's budget has stopped being a pacing problem and
683
+ * become a stall, which #743's write deadline, streak and wedge timer already
684
+ * own — and they own it by DEMOTING and respawning, which pacing can never do.
685
+ * Once latched, this gate steps aside for the rest of the client's life and
686
+ * every notify takes the pre-#1714 path. It re-arms on the only event that
687
+ * means the stall is over: a new client generation, which gets a new record.
688
+ *
689
+ * Cleared wholesale by the service teardown (`session_start` runs through it),
690
+ * by {@link demoteForNotifyStall}, and per key whenever the client identity
691
+ * changes, so no count can outlive the client it describes.
692
+ */
693
+ auxNotifyInflight = new Map();
694
+ /**
695
+ * #1459: the ONE outstanding auxiliary notify write per server key
696
+ * ("serverId:normalizedRoot"). A `reopenOnResync` scanner re-parses the whole
697
+ * file on every `didOpen`, so a `clientScope: "all"` sweep that fans out
698
+ * across a neighbour set pushes N full re-scans at it inside a few
699
+ * milliseconds; its stdin stops draining and the #743 write deadline expires
700
+ * for each one, which walked the breaker open in three touches. The gate keeps
701
+ * a sweep to one in-flight resync per auxiliary: while one is outstanding the
702
+ * next touch DEFERS its write and reports the server as uncovered instead of
703
+ * adding to the flood. `startedAt` dates the outstanding write so a write that
704
+ * never lands is still demoted (see {@link demoteForNotifyStall}).
705
+ */
706
+ outstandingAuxNotifyWrites = new Map();
578
707
  /** LRU clock for capacity eviction, keyed by the canonical server/root key. */
579
708
  clientLastUsedAt = new Map();
580
709
  /**
@@ -636,10 +765,13 @@ export class LSPService {
636
765
  if (!key.startsWith(prefix))
637
766
  continue;
638
767
  const ancestorKey = key.slice(prefix.length);
639
- const relative = path.relative(ancestorKey, rootKey);
768
+ const pathApi = isWindowsPath(ancestorKey) || isWindowsPath(rootKey)
769
+ ? path.win32
770
+ : path;
771
+ const relative = pathApi.relative(ancestorKey, rootKey);
640
772
  if (relative === "" ||
641
773
  relative.startsWith("..") ||
642
- path.isAbsolute(relative)) {
774
+ pathApi.isAbsolute(relative)) {
643
775
  continue;
644
776
  }
645
777
  if (!nearestAncestor || ancestorKey.length > nearestAncestor.length) {
@@ -941,6 +1073,22 @@ export class LSPService {
941
1073
  return;
942
1074
  }
943
1075
  this.notifyWriteBackpressureStreak.delete(key);
1076
+ this.demoteForNotifyStall(key, entry, filePath, {
1077
+ consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
1078
+ });
1079
+ }
1080
+ /**
1081
+ * The demotion itself, shared by the #743 consecutive-timeout streak and the
1082
+ * #1459 wedged-write rule. Both mean the same thing — this client's input path
1083
+ * is not moving — and both need the same teardown.
1084
+ */
1085
+ demoteForNotifyStall(key, entry, filePath, reason) {
1086
+ this.notifyWriteBackpressureStreak.delete(key);
1087
+ this.outstandingAuxNotifyWrites.delete(key);
1088
+ // #1714: the demoted client is torn down, so its backlog count describes a
1089
+ // process that no longer exists. Leaving it would make the replacement start
1090
+ // at the ceiling and pay a barrier on its first file.
1091
+ this.auxNotifyInflight.delete(key);
944
1092
  this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
945
1093
  void entry.client.shutdown().catch(() => { });
946
1094
  this.state.clients.delete(key);
@@ -956,10 +1104,432 @@ export class LSPService {
956
1104
  metadata: {
957
1105
  serverId: entry.info.id,
958
1106
  cooldownMs: BROKEN_BASE_COOLDOWN_MS,
959
- consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
1107
+ ...reason,
1108
+ },
1109
+ });
1110
+ }
1111
+ /**
1112
+ * #1459: the caller's write deadline is a LATENCY bound, not a health verdict.
1113
+ * A scanner whose `didOpen` lands a second after we stopped waiting is slow,
1114
+ * not broken, so its late success retracts the timeout it was charged for.
1115
+ * Without this, three slow-but-healthy scans in a row opened the breaker and
1116
+ * blacked out the security lane for 15 s.
1117
+ */
1118
+ retractNotifyWriteBackpressure(key, serverId, filePath, outstandingMs, client) {
1119
+ // Generation-checked, exactly like the gate: a predecessor's late landing
1120
+ // must not decrement its SUCCESSOR's streak and mask a real stall.
1121
+ if (this.state.clients.get(key) !== client)
1122
+ return;
1123
+ const streak = this.notifyWriteBackpressureStreak.get(key);
1124
+ if (!streak)
1125
+ return;
1126
+ const streakAfter = streak - 1;
1127
+ if (streakAfter <= 0)
1128
+ this.notifyWriteBackpressureStreak.delete(key);
1129
+ else
1130
+ this.notifyWriteBackpressureStreak.set(key, streakAfter);
1131
+ logLatency({
1132
+ type: "phase",
1133
+ phase: "lsp_notify_write_late_landed",
1134
+ filePath: normalizeMapKey(filePath),
1135
+ durationMs: outstandingMs,
1136
+ metadata: { serverId, outstandingMs, streakAfter },
1137
+ });
1138
+ }
1139
+ /**
1140
+ * #1714: record that one more document notify went to this auxiliary.
1141
+ *
1142
+ * Counted at ISSUE time, not on the write's settle: the backlog the sweep
1143
+ * builds is what the server still has to read, and a write that has not landed
1144
+ * yet is part of it. A client-identity change resets the count, because a
1145
+ * respawned server carries none of its predecessor's backlog.
1146
+ */
1147
+ noteAuxNotifyIssued(key, client) {
1148
+ const record = this.auxNotifyInflight.get(key);
1149
+ if (record && record.client === client) {
1150
+ record.unacked += 1;
1151
+ return;
1152
+ }
1153
+ this.auxNotifyInflight.set(key, { client, unacked: 1 });
1154
+ }
1155
+ /**
1156
+ * #1714: is this auxiliary already holding as many documents as it may?
1157
+ *
1158
+ * Read by the sweep's PRE-OPEN pass, which writes `didOpen` directly instead
1159
+ * of going through `touchFile` and so meets neither the #1459 slot gate nor
1160
+ * the drain barrier. Pre-opening is explicitly best-effort — the file's own
1161
+ * `touchFile` opens it a moment later, through the barrier — so a backlogged
1162
+ * scanner is simply left out of the warm-up burst rather than handed a second
1163
+ * copy of every file in the chunk.
1164
+ */
1165
+ auxNotifyBacklogAtCeiling(key, entry) {
1166
+ const record = this.auxNotifyInflight.get(key);
1167
+ if (!record || record.client !== entry.client)
1168
+ return false;
1169
+ // Latched open: this scanner is a stall, not a pacing problem, and the
1170
+ // breaker owns it. Step aside here too rather than half-throttling it.
1171
+ if (record.gateOpen)
1172
+ return false;
1173
+ return record.unacked >= auxNotifyInflightLimit(entry.info);
1174
+ }
1175
+ /**
1176
+ * #1714: hold the next notify until this auxiliary has proven it PROCESSED the
1177
+ * ones already sent. Returns when the caller may write — it never refuses.
1178
+ *
1179
+ * Under the limit there is nothing to prove, so the common case returns without
1180
+ * awaiting anything and the sweep runs at full speed. At the limit the gate
1181
+ * sends ONE request round-trip (`pingLiveness`, #1277,
1182
+ * clients/lsp/client.ts:2577).
1183
+ *
1184
+ * WHY A REPLY PROVES PROCESSING, measured rather than assumed. ast-grep-lsp is
1185
+ * tower-lsp-server and drains its message stream in order on one task, so a
1186
+ * request written after N `didOpen`s is answered after those N are scanned.
1187
+ * Live probe against the real binary, 30 real repository files:
1188
+ *
1189
+ * idle `workspace/symbol` reply 0 ms
1190
+ * after 30 didOpens 2263 ms, 29 of 30 publishes already in
1191
+ * all 30 processed 2330 ms
1192
+ *
1193
+ * idle `textDocument/hover` reply 1 ms
1194
+ * after 30 didOpens 2086 ms, 29 of 30 publishes already in
1195
+ *
1196
+ * Idle-zero to loaded-seconds, landing within one document of the whole
1197
+ * backlog, is the ordering property this gate needs; one document of slack is
1198
+ * immaterial against a ceiling of 4 to 8.
1199
+ *
1200
+ * A server that answered requests off a SEPARATE task would not give this
1201
+ * proof. What keeps that server safe is NOT the fail-open latch below: the
1202
+ * latch never arms for it. Such a server answers the barrier instantly,
1203
+ * `unacked` resets, and the gate stays inert for the whole sweep. Safety comes
1204
+ * from the outcome that inertness produces — the notify sequence is exactly
1205
+ * the pre-#1714 one, and #743's write deadline, backpressure streak and wedge
1206
+ * timer own the stall the same way they did before this change. The throttle
1207
+ * buys such a server nothing; it also costs it nothing.
1208
+ *
1209
+ * The wait is bounded by the CALLER's remaining budget, never by a schedule of
1210
+ * its own: every waiter gives the shared round-trip only `waitMs`, so a caller
1211
+ * that asked for a 1 s touch still gets one.
1212
+ *
1213
+ * A waiter whose budget runs out does NOT defer the file. It latches the gate
1214
+ * open and falls through to the write. Pacing exists to stop a healthy-but-slow
1215
+ * scanner drowning; a scanner that will not answer at all is a stall, and #743's
1216
+ * write deadline, backpressure streak and wedge timer already own that case —
1217
+ * they demote and respawn it, which is the self-heal that recovered the live
1218
+ * session. Deferring instead withheld the write, accrued no strike, and left
1219
+ * the sweep with no exit.
1220
+ *
1221
+ * Fails OPEN for a client with no liveness round-trip: unmeasurable is not the
1222
+ * same as backlogged, and the codebase already reads this capability as
1223
+ * `pingLiveness?.() ?? true` (clients/lsp/client.ts:281). Every real client
1224
+ * provides it.
1225
+ */
1226
+ async paceAuxNotify(key, entry, filePath, waitMs, context) {
1227
+ const record = this.auxNotifyInflight.get(key);
1228
+ if (!record)
1229
+ return;
1230
+ if (record.client !== entry.client) {
1231
+ // A previous generation's backlog says nothing about this client.
1232
+ this.auxNotifyInflight.delete(key);
1233
+ return;
1234
+ }
1235
+ // Already handed to the breaker: no barrier, no wait, no extra cost per
1236
+ // file. This is what stops a stalled scanner turning every remaining file
1237
+ // into a fresh full-budget wait.
1238
+ if (record.gateOpen)
1239
+ return;
1240
+ const limit = auxNotifyInflightLimit(entry.info);
1241
+ if (record.unacked < limit)
1242
+ return;
1243
+ const ping = entry.client.pingLiveness;
1244
+ if (!ping) {
1245
+ record.unacked = 0;
1246
+ return;
1247
+ }
1248
+ if (waitMs <= 0) {
1249
+ this.openAuxNotifyGate(key, entry, filePath, context, {
1250
+ unacked: record.unacked,
1251
+ limit,
1252
+ waitMs,
1253
+ durationMs: 0,
1254
+ });
1255
+ return;
1256
+ }
1257
+ if (!record.drain) {
1258
+ const startedAt = Date.now();
1259
+ const outstanding = record.unacked;
1260
+ const client = entry.client;
1261
+ const barrier = (async () => {
1262
+ let drained = false;
1263
+ try {
1264
+ drained = await ping.call(client, waitMs);
1265
+ }
1266
+ catch {
1267
+ // A ping that throws proves nothing about the backlog; treat it as
1268
+ // undrained rather than waving the next write through.
1269
+ drained = false;
1270
+ }
1271
+ const current = this.auxNotifyInflight.get(key);
1272
+ if (current === record && current.client === client) {
1273
+ current.drain = undefined;
1274
+ // Subtract the snapshot rather than zeroing: a concurrent touch may
1275
+ // have issued a write after this round-trip was sent, and that write
1276
+ // is still unacknowledged.
1277
+ if (drained)
1278
+ current.unacked = Math.max(0, current.unacked - outstanding);
1279
+ // Deliberately no `else` latch here. A barrier is only ever created
1280
+ // by a call that goes on to await it, so a negative result always
1281
+ // reaches a waiter — as `false`, or as that waiter's own timeout —
1282
+ // and the waiter is what latches. A second latch here would be
1283
+ // unreachable, and no test could hold it honest.
1284
+ }
1285
+ this.noteDrainBarrierOutcome(key, entry, filePath, context, {
1286
+ unacked: outstanding,
1287
+ limit,
1288
+ waitMs,
1289
+ durationMs: Date.now() - startedAt,
1290
+ outcome: drained ? "drained" : "stalled",
1291
+ });
1292
+ return drained;
1293
+ })();
1294
+ barrier.catch(() => { });
1295
+ record.drain = barrier;
1296
+ record.stallLogged = false;
1297
+ }
1298
+ // Each waiter spends only its OWN remaining budget on the shared barrier.
1299
+ const waitStartedAt = Date.now();
1300
+ const drained = await withDeadline(record.drain, {
1301
+ ms: waitMs,
1302
+ onTimeout: "undefined",
1303
+ onReject: "undefined",
1304
+ });
1305
+ if (drained !== true) {
1306
+ // The waiter gave up before the round-trip answered. Latch open and let
1307
+ // the caller write: a barrier whose ping never answers would otherwise
1308
+ // tax every remaining file a full budget for nothing.
1309
+ this.openAuxNotifyGate(key, entry, filePath, context, {
1310
+ unacked: record.unacked,
1311
+ limit,
1312
+ waitMs,
1313
+ durationMs: Date.now() - waitStartedAt,
1314
+ });
1315
+ }
1316
+ }
1317
+ /**
1318
+ * #1714: stop pacing this client and hand it to the breaker.
1319
+ *
1320
+ * Latched, not cooled down — a timer here would be a schedule of this gate's
1321
+ * own, and it would race the caller's cadence. The latch clears only when the
1322
+ * record does: a demotion ({@link demoteForNotifyStall}), a client-identity
1323
+ * change, or the service teardown. Every one of those means a new server
1324
+ * process, which is the only event that makes the old backlog meaningless.
1325
+ */
1326
+ openAuxNotifyGate(key, entry, filePath, context, detail) {
1327
+ const record = this.auxNotifyInflight.get(key);
1328
+ if (record && record.client === entry.client) {
1329
+ // `drain` is deliberately left alone. Once the gate is open nothing
1330
+ // reads it again — `paceAuxNotify` returns above the barrier — and the
1331
+ // round-trip's own settle handler clears it when it finally answers.
1332
+ // Clearing it here would be a write no test could hold honest.
1333
+ record.gateOpen = true;
1334
+ }
1335
+ this.noteDrainBarrierOutcome(key, entry, filePath, context, {
1336
+ ...detail,
1337
+ outcome: "stalled",
1338
+ });
1339
+ }
1340
+ /**
1341
+ * #1714: emit at most ONE record per barrier. A stalled barrier can be
1342
+ * abandoned by every file left in the sweep, and one row per file would turn a
1343
+ * single stuck scanner into hundreds of identical records.
1344
+ */
1345
+ noteDrainBarrierOutcome(key, entry, filePath, context, detail) {
1346
+ const record = this.auxNotifyInflight.get(key);
1347
+ if (detail.outcome === "stalled" && record) {
1348
+ if (record.stallLogged)
1349
+ return;
1350
+ record.stallLogged = true;
1351
+ }
1352
+ this.logDrainBarrier(key, entry, filePath, context, detail);
1353
+ }
1354
+ /**
1355
+ * #1714: one row per barrier, not per waiter — a burst of N notifies produces
1356
+ * at most one round-trip and one record, so the volume is bounded by the sweep
1357
+ * divided by the limit. Names the server and the file that hit the ceiling, so
1358
+ * "which scanner is falling behind" survives aggregation.
1359
+ */
1360
+ logDrainBarrier(key, entry, filePath, context, detail) {
1361
+ if (detail.outcome === "stalled") {
1362
+ incrementDegradationCount({
1363
+ kind: "lsp-notify-inflight-stall",
1364
+ subject: `${entry.info.id}:${normalizeMapKey(filePath)}`,
1365
+ reason: `notify barrier stalled with ${detail.unacked} unacknowledged writes`,
1366
+ });
1367
+ }
1368
+ logLatency({
1369
+ type: "phase",
1370
+ phase: "lsp_notify_inflight_barrier",
1371
+ filePath: normalizeMapKey(filePath),
1372
+ durationMs: detail.durationMs,
1373
+ metadata: {
1374
+ serverId: entry.info.id,
1375
+ clientKey: key,
1376
+ source: context.source,
1377
+ clientScope: context.clientScope,
1378
+ ...detail,
960
1379
  },
961
1380
  });
962
1381
  }
1382
+ /**
1383
+ * #1459: take this auxiliary's resync slot, waiting up to `budgetMs` for it.
1384
+ *
1385
+ * The gate is a QUEUE, not a drop: a healthy scanner accepts a `didOpen` in
1386
+ * milliseconds, so a sweep's neighbours take their turns one after another and
1387
+ * every file still gets scanned — what the gate prevents is N simultaneous
1388
+ * full re-scans flooding one stdin. Only a scanner that cannot accept a write
1389
+ * inside the budget makes a waiter give up, and giving up is reported as a
1390
+ * coverage gap rather than pushed anyway.
1391
+ *
1392
+ * The slot is CLAIMED SYNCHRONOUSLY: the check and the insert sit in one
1393
+ * uninterrupted run of statements, and the returned handle owns the entry. A
1394
+ * version that returned "the slot looks free, go write" and let the caller
1395
+ * insert its own record after an `await` was not a gate at all — when the
1396
+ * holder's write landed, every waiter woke in the same microtask batch, each
1397
+ * read an empty map, and all of them wrote at once (measured: one write at t=0,
1398
+ * then a five-wide flood at t=50 for six touches). That is #1459's own root
1399
+ * cause rebuilt inside the fix for it.
1400
+ *
1401
+ * Returns a handle with `release()` (call on the write's settle, idempotent), or
1402
+ * a verdict naming how long the blocking write has been outstanding.
1403
+ */
1404
+ async claimAuxNotifySlot(clientKey, entry, filePath, budgetMs) {
1405
+ const deadline = Date.now() + budgetMs;
1406
+ for (;;) {
1407
+ const outstanding = this.outstandingAuxNotifyWrites.get(clientKey);
1408
+ // A record left behind by a PREVIOUS client generation (evicted,
1409
+ // respawned) says nothing about this client's stdin — drop it, so a stale
1410
+ // entry can never starve a healthy server.
1411
+ if (outstanding && outstanding.client !== entry.client) {
1412
+ this.outstandingAuxNotifyWrites.delete(clientKey);
1413
+ }
1414
+ else if (outstanding) {
1415
+ const outstandingMs = Date.now() - outstanding.startedAt;
1416
+ const remainingMs = deadline - Date.now();
1417
+ if (remainingMs <= 0)
1418
+ return { outstandingMs };
1419
+ // `settled` never rejects, so this only resolves or times out.
1420
+ await withDeadline(outstanding.settled, {
1421
+ ms: remainingMs,
1422
+ onTimeout: "undefined",
1423
+ onReject: "undefined",
1424
+ });
1425
+ continue;
1426
+ }
1427
+ // This client was evicted or replaced while we queued — writing to it
1428
+ // would target a retired generation. Report the gap instead.
1429
+ //
1430
+ // `!== entry.client` covers BOTH shapes, and the missing-entry one is the
1431
+ // dangerous half: eviction (idle, capacity, a #743 demotion) DELETES the
1432
+ // registry entry, so an `undefined`-exempting guard would wave the waiter
1433
+ // through to a corpse whose write resolves `true` — and `markTouched` would
1434
+ // then record this content as delivered, which is exactly the #1253
1435
+ // laundering the debounce entry must never do.
1436
+ const current = this.state.clients.get(clientKey);
1437
+ if (current !== entry.client) {
1438
+ return { outstandingMs: 0 };
1439
+ }
1440
+ // ---- No `await` from here to the `set` below: the claim is atomic. ----
1441
+ let resolveSettled;
1442
+ const settled = new Promise((resolve) => {
1443
+ resolveSettled = resolve;
1444
+ });
1445
+ const token = {
1446
+ startedAt: Date.now(),
1447
+ client: entry.client,
1448
+ settled,
1449
+ // A write nothing accepts for the whole wedge window is a dead input
1450
+ // path, not a slow scan. Armed HERE rather than checked by the next
1451
+ // waiter: inside a burst every waiter arrives within one budget, so a
1452
+ // waiter-side check could never see the wedge window elapse and a
1453
+ // wedged scanner was never demoted. Unref'd so it cannot hold a
1454
+ // one-shot host alive, and cleared on release.
1455
+ wedgeTimer: setTimeout(() => {
1456
+ if (this.outstandingAuxNotifyWrites.get(clientKey) !== token)
1457
+ return;
1458
+ this.demoteForNotifyStall(clientKey, entry, filePath, {
1459
+ outstandingMs: Date.now() - token.startedAt,
1460
+ });
1461
+ resolveSettled?.();
1462
+ }, notifyWedgedMs()),
1463
+ };
1464
+ token.wedgeTimer.unref?.();
1465
+ this.outstandingAuxNotifyWrites.set(clientKey, token);
1466
+ return {
1467
+ release: () => {
1468
+ clearTimeout(token.wedgeTimer);
1469
+ if (this.outstandingAuxNotifyWrites.get(clientKey) === token) {
1470
+ this.outstandingAuxNotifyWrites.delete(clientKey);
1471
+ }
1472
+ resolveSettled?.();
1473
+ },
1474
+ };
1475
+ }
1476
+ }
1477
+ /**
1478
+ * #1459: the auxiliary scanners that WOULD have attached to this touch but got
1479
+ * no client because their circuit breaker is open (cooldown or permanent).
1480
+ *
1481
+ * A skipped scanner said nothing about the file. Until now it also left no
1482
+ * trace on the result: it simply dropped out of `spawned`, and the touch
1483
+ * resolved `confirmation: "confirmed"` on the strength of whoever was left —
1484
+ * so a 15 s opengrep cooldown read as "scanned, clean" for every file swept
1485
+ * inside it. Naming the scanners here narrows the touch to `"partial"` instead,
1486
+ * which every coverage consumer already fails closed on (#1470).
1487
+ *
1488
+ * Deliberately AUXILIARY-only. A broken primary is already visible through the
1489
+ * `no_clients` failure kind and the demonstrated-cold path; the false-clean
1490
+ * hazard this addresses is the scanner lane, where an empty result is the
1491
+ * normal, expected answer.
1492
+ *
1493
+ * SCOPE, stated so the next reader does not assume the room is closed: this
1494
+ * covers the BREAKER doors only (cooldown + permanent). `ensureClientForServer`
1495
+ * also drops a scanner for a temporarily-unavailable command (the #1496 latch),
1496
+ * `optionalDisabled`, a spawn failure, or capacity eviction. Those are the same
1497
+ * defect class in the same lane, but they are persistent-absence states rather
1498
+ * than a transient blackout, so flagging every touch partial for them is a
1499
+ * broader behavior change than #1459 is scoped to make. Tracked separately.
1500
+ */
1501
+ async brokenSkippedAuxiliaryServerIds(filePath, clientScope, options, spawned) {
1502
+ // "primary" scope attaches no auxiliaries at all, so nothing was skipped.
1503
+ if (clientScope === "primary")
1504
+ return [];
1505
+ const enabledAuxiliaries = clientScope === "with-auxiliary"
1506
+ ? new Set(options.auxiliaryServerIds ?? [])
1507
+ : undefined;
1508
+ const attached = new Set(spawned.map((entry) => entry.info.id));
1509
+ const skipped = [];
1510
+ for (const server of getServersForFileWithConfig(filePath)) {
1511
+ if (server.role !== "auxiliary")
1512
+ continue;
1513
+ if (attached.has(server.id))
1514
+ continue;
1515
+ // An explicitly excluded server (the #584 workspace-sweep exclusion) was
1516
+ // never asked, and its findings come from its own CLI extractor — that is
1517
+ // a routing decision, not a coverage gap.
1518
+ if (options.excludeServerIds?.has(server.id))
1519
+ continue;
1520
+ if (enabledAuxiliaries && !enabledAuxiliaries.has(server.id))
1521
+ continue;
1522
+ const key = await this.demonstratedReadyKeyFor(server, filePath);
1523
+ if (!key)
1524
+ continue;
1525
+ const brokenUntil = this.state.broken.get(key);
1526
+ if (this.permanentlyBroken.has(key) ||
1527
+ (typeof brokenUntil === "number" && brokenUntil > Date.now())) {
1528
+ skipped.push(server.id);
1529
+ }
1530
+ }
1531
+ return skipped;
1532
+ }
963
1533
  activeClientsForCwd(cwd, priorityServerIds = []) {
964
1534
  const normalizedCwd = normalizeMapKey(cwd);
965
1535
  const priority = new Map(priorityServerIds.map((serverId, index) => [serverId, index]));
@@ -984,7 +1554,7 @@ export class LSPService {
984
1554
  * Get or create LSP client for a file
985
1555
  * Prevents duplicate client creation via in-flight promise tracking
986
1556
  */
987
- async getClientForFile(filePath, maxWaitMs, hardCapMs) {
1557
+ async getClientForFile(filePath, maxWaitMs, hardCapMs, resolvedRoots, waitSkipReasons) {
988
1558
  if (this.checkDestroyed())
989
1559
  return undefined;
990
1560
  // Primary selection considers language servers only — auxiliary servers
@@ -1003,12 +1573,25 @@ export class LSPService {
1003
1573
  ? Math.min(serverBaseMs, hardCapMs)
1004
1574
  : hardCapMs
1005
1575
  : serverBaseMs;
1576
+ let knownSlowResolve;
1577
+ const knownSlowSentinel = Symbol("lsp-client-wait-known-slow");
1578
+ const knownSlow = new Promise((resolve) => {
1579
+ knownSlowResolve = () => resolve(knownSlowSentinel);
1580
+ });
1581
+ const noteSpawnInFlight = (serverId) => {
1582
+ const knownDurationMs = getSuccessfulLspSpawnDurationMs(serverId);
1583
+ if (knownDurationMs !== undefined && knownDurationMs > effectiveMaxWaitMs * 2) {
1584
+ // Let a completion microtask already queued by the acquisition win
1585
+ // before the shortcut decision is observed by Promise.race.
1586
+ queueMicrotask(() => knownSlowResolve?.());
1587
+ }
1588
+ };
1006
1589
  const withBudget = async () => {
1007
1590
  if (servers.length === 0)
1008
1591
  return undefined;
1009
1592
  // Try each matching server
1010
1593
  for (const server of servers) {
1011
- const spawned = await this.ensureClientForServer(filePath, server);
1594
+ const spawned = await this.ensureClientForServer(filePath, server, resolvedRoots, noteSpawnInFlight);
1012
1595
  if (spawned) {
1013
1596
  logLatency({
1014
1597
  type: "phase",
@@ -1066,6 +1649,7 @@ export class LSPService {
1066
1649
  try {
1067
1650
  waitResult = await Promise.race([
1068
1651
  withBudget(),
1652
+ knownSlow,
1069
1653
  new Promise((resolve) => {
1070
1654
  waitTimer = setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs);
1071
1655
  }),
@@ -1075,6 +1659,32 @@ export class LSPService {
1075
1659
  if (waitTimer)
1076
1660
  clearTimeout(waitTimer);
1077
1661
  }
1662
+ if (waitResult === knownSlowSentinel) {
1663
+ // `inFlight` is cleared in ensureClientForServer's finally block, so a
1664
+ // settled acquisition can still be present here. Re-read the published
1665
+ // clients at the decision point; a usable client outranks the sentinel.
1666
+ for (const server of servers) {
1667
+ const root = await this.resolveServerRoot(server, filePath);
1668
+ const client = root
1669
+ ? this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`)
1670
+ : undefined;
1671
+ if (client?.isAlive())
1672
+ return { client, info: server };
1673
+ }
1674
+ waitSkipReasons?.add("budget_skipped_known_slow");
1675
+ logLatency({
1676
+ type: "phase",
1677
+ phase: "lsp_client_wait_skipped",
1678
+ filePath,
1679
+ durationMs: 0,
1680
+ metadata: {
1681
+ maxWaitMs: effectiveMaxWaitMs,
1682
+ serverIds: servers.map((server) => server.id),
1683
+ reason: "budget_skipped_known_slow",
1684
+ },
1685
+ });
1686
+ return undefined;
1687
+ }
1078
1688
  if (waitResult === timeoutSentinel) {
1079
1689
  // Snapshot known client health — scan by serverId prefix (no root needed)
1080
1690
  const knownHealth = [...this.state.clients.entries()]
@@ -1104,7 +1714,7 @@ export class LSPService {
1104
1714
  * Get or create ALL LSP clients that can serve a file.
1105
1715
  * Used for diagnostics aggregation across complementary servers.
1106
1716
  */
1107
- async getClientsForFile(filePath, excludeServerIds) {
1717
+ async getClientsForFile(filePath, excludeServerIds, resolvedRoots) {
1108
1718
  const allServers = getServersForFileWithConfig(filePath);
1109
1719
  const servers = excludeServerIds && excludeServerIds.size > 0
1110
1720
  ? allServers.filter((s) => !excludeServerIds.has(s.id))
@@ -1115,7 +1725,7 @@ export class LSPService {
1115
1725
  // that fail the root check are not real spawn attempts.
1116
1726
  const roots = await Promise.all(servers.map((s) => this.resolveServerRoot(s, filePath)));
1117
1727
  const serverCountAttempted = roots.filter(Boolean).length;
1118
- const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
1728
+ const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server, resolvedRoots)));
1119
1729
  return {
1120
1730
  clients: spawned.filter((entry) => Boolean(entry)),
1121
1731
  serverCountAttempted,
@@ -1175,7 +1785,194 @@ export class LSPService {
1175
1785
  }
1176
1786
  return false;
1177
1787
  }
1178
- async ensureClientForServer(filePath, server) {
1788
+ /**
1789
+ * #1668: deliver a `workspace/didChangeWatchedFiles` event for a disk
1790
+ * change the client did not author through open-document sync — a bash
1791
+ * write/delete, or any other external change. `type` is the LSP
1792
+ * `FileChangeType` (1 Created, 2 Changed, 3 Deleted).
1793
+ *
1794
+ * Only reaches ALREADY-ACTIVE clients for this file's servers — a server
1795
+ * that hasn't been spawned yet has no stale cache to correct, so this
1796
+ * never spawns one just to deliver the notification. Each affected
1797
+ * client enqueues into its own #271 debounced queue, so a burst of
1798
+ * external changes still coalesces into one notification per server.
1799
+ */
1800
+ async notifyExternalFileChange(filePath, type) {
1801
+ if (this.checkDestroyed())
1802
+ return;
1803
+ for (const server of getServersForFileWithConfig(filePath)) {
1804
+ const root = await this.resolveServerRoot(server, filePath);
1805
+ if (!root)
1806
+ continue;
1807
+ const key = `${server.id}:${normalizeMapKey(root)}`;
1808
+ const existing = this.state.clients.get(key);
1809
+ if (existing?.isAlive()) {
1810
+ existing.notify.watchedFileChange(filePath, type);
1811
+ }
1812
+ }
1813
+ }
1814
+ /**
1815
+ * #1783: disk-drift backstop. Stat one batch of the documents a language
1816
+ * server currently holds, and re-push any whose bytes on disk no longer
1817
+ * match what the server was last given.
1818
+ *
1819
+ * Why this exists: an edit made outside the tracked write/edit path (a
1820
+ * bash-tool bulk edit) sends no `didChange`, so the server keeps publishing
1821
+ * pre-edit diagnostics with nothing to correct it. The resync-on-read path
1822
+ * only fires for a file the session reads again; a file that is edited and
1823
+ * never re-read stayed stale for the life of the server.
1824
+ *
1825
+ * Hot-path contract: callers fire this WITHOUT awaiting it. It is
1826
+ * rate-limited to one pass per drift-check interval (10s default),
1827
+ * stats at most 64 documents per pass, reads only the ones whose stat
1828
+ * already diverged, and issues at most 4 resyncs per pass serially. So the
1829
+ * steady-state cost on the touch path is zero, and the worst-case cost of a
1830
+ * pass is 64 stats plus up to 4 reads, off the caller's critical path.
1831
+ *
1832
+ * Never spawns: a document with no live client already holding it open has
1833
+ * no stale view to correct, so its record is dropped instead of resynced.
1834
+ */
1835
+ async sweepDocumentDrift(options = {}) {
1836
+ if (this.checkDestroyed())
1837
+ return undefined;
1838
+ return this.documentDrift.sweep({
1839
+ resync: async (filePath, content) => {
1840
+ // Reuse the normal touch path so the resync inherits the existing
1841
+ // per-server notify-write budget, the #743 backpressure demotion and
1842
+ // the client-lease machinery. diagnostics:"none" keeps it a pure
1843
+ // content push — the next genuine query gets correct diagnostics
1844
+ // because the server's view is now right, not because this call
1845
+ // waited for them.
1846
+ //
1847
+ // Scope "all" MINUS the servers that are not holding this document:
1848
+ // the resync must cover every view that is actually stale, primary
1849
+ // and auxiliary alike, and the exclusion set is what keeps that from
1850
+ // spawning anything (see serverIdsNotHoldingDocument). The write is
1851
+ // still one per held server through the same bounded notify path, so
1852
+ // the per-pass resync cap continues to bound the total writes.
1853
+ await this.touchFile(filePath, content, {
1854
+ diagnostics: "none",
1855
+ source: "drift_resync",
1856
+ clientScope: "all",
1857
+ excludeServerIds: await this.serverIdsNotHoldingDocument(filePath),
1858
+ });
1859
+ // touchFile swallows a rejected or timed-out notify write so the
1860
+ // caller's edit keeps moving, so its return proves nothing about
1861
+ // whether the heal landed. The drift record is stamped in exactly
1862
+ // one place — recordFullyCoveredSync, and only on full coverage —
1863
+ // so "did the record advance to this content" IS the answer.
1864
+ return (this.documentDrift.peek(filePath)?.fingerprint ===
1865
+ fingerprintDocumentContent(content));
1866
+ },
1867
+ holdsDocument: (filePath) => this.hasLiveClientHoldingDocument(filePath),
1868
+ onDrift: (event) => {
1869
+ // A clean re-stamp, a deleted file and a closed document are all
1870
+ // normal bookkeeping, not degradations. Only a real resync — or a
1871
+ // heal the pacing had to defer — earns a record.
1872
+ if (event.disposition === "unchanged" ||
1873
+ event.disposition === "vanished" ||
1874
+ event.disposition === "unheld") {
1875
+ return;
1876
+ }
1877
+ // Bounded record, per-file so the identity of the stuck document
1878
+ // survives aggregation. `incrementDegradationCount` keeps one entry
1879
+ // per file with an exact repeat tally instead of N entries.
1880
+ incrementDegradationCount({
1881
+ kind: "lsp-document-drift",
1882
+ subject: event.filePath,
1883
+ reason: event.disposition === "deferred"
1884
+ ? `disk drift deferred by resync pacing after ${event.driftAgeMs}ms`
1885
+ : event.disposition === "failed"
1886
+ ? `resync FAILED after ${event.driftAgeMs}ms of untracked disk drift; view still stale (${event.syncedSize}->${event.diskSize} bytes)`
1887
+ : `resynced after ${event.driftAgeMs}ms of untracked disk drift (${event.syncedSize}->${event.diskSize} bytes)`,
1888
+ });
1889
+ logLatency({
1890
+ type: "phase",
1891
+ phase: "lsp_document_drift",
1892
+ filePath: event.filePath,
1893
+ durationMs: event.driftAgeMs,
1894
+ metadata: {
1895
+ disposition: event.disposition,
1896
+ driftAgeMs: event.driftAgeMs,
1897
+ syncedSize: event.syncedSize,
1898
+ diskSize: event.diskSize,
1899
+ },
1900
+ });
1901
+ },
1902
+ }, options);
1903
+ }
1904
+ /** #1783: tracked drift-record count. Test seam for the reset conformance. */
1905
+ _driftTrackedCountForTests() {
1906
+ return this.documentDrift.size;
1907
+ }
1908
+ /**
1909
+ * #1783: does any LIVE client already hold this document open? A record for
1910
+ * a document no server holds is dropped rather than resynced, so the drift
1911
+ * backstop can never spawn a server just to correct a view nobody has.
1912
+ */
1913
+ hasLiveClientHoldingDocument(filePath) {
1914
+ for (const client of this.state.clients.values()) {
1915
+ if (!client.isAlive())
1916
+ continue;
1917
+ if (documentIsOpenOn(client, filePath))
1918
+ return true;
1919
+ }
1920
+ return false;
1921
+ }
1922
+ /**
1923
+ * #1783: server ids for this file whose client is NOT currently holding the
1924
+ * document open — the exclusion set the drift resync passes to
1925
+ * `clientScope:"all"`.
1926
+ *
1927
+ * This is what keeps "resync every server that holds it" and "never spawn"
1928
+ * compatible. `getClientsForFile` filters by this set BEFORE
1929
+ * `ensureClientForServer`, so an excluded server is never reached, and every
1930
+ * remaining server already has a live client that returns from the cache.
1931
+ * Without it, `clientScope:"all"` would spawn the file's other servers, and
1932
+ * `clientScope:"primary"` would leave an auxiliary's view stale while the
1933
+ * record claimed the file was back in sync.
1934
+ */
1935
+ async serverIdsNotHoldingDocument(filePath) {
1936
+ const exclude = new Set();
1937
+ for (const server of getServersForFileWithConfig(filePath)) {
1938
+ const root = await this.resolveServerRoot(server, filePath);
1939
+ const existing = root
1940
+ ? this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`)
1941
+ : undefined;
1942
+ if (existing?.isAlive() && documentIsOpenOn(existing, filePath))
1943
+ continue;
1944
+ exclude.add(server.id);
1945
+ }
1946
+ return exclude;
1947
+ }
1948
+ /**
1949
+ * #1783: stamp the drift record only when this touch reached EVERY server
1950
+ * that currently holds the document, and every one of those writes landed.
1951
+ *
1952
+ * `at` is the touch's START time, not the moment the write landed. The write
1953
+ * can take up to the notify budget, and an untracked edit inside that window
1954
+ * would otherwise be stamped as already-synchronized and become invisible
1955
+ * (the mtime half of the key compares against this timestamp). Starting the
1956
+ * clock at the touch — within a millisecond or two of the caller's read —
1957
+ * narrows that blind window to the caller's own read-to-touch gap.
1958
+ */
1959
+ recordFullyCoveredSync(filePath, content, targeted, allWritesLanded, at) {
1960
+ if (!allWritesLanded || targeted.length === 0)
1961
+ return;
1962
+ const targetedClients = new Set(targeted.map((entry) => entry.client));
1963
+ for (const client of this.state.clients.values()) {
1964
+ if (targetedClients.has(client))
1965
+ continue;
1966
+ if (!client.isAlive())
1967
+ continue;
1968
+ // A live client outside this touch's scope still holds the document, so
1969
+ // its view is NOT covered by this content. Recording here would claim it.
1970
+ if (documentIsOpenOn(client, filePath))
1971
+ return;
1972
+ }
1973
+ this.documentDrift.recordSynced(filePath, content, at);
1974
+ }
1975
+ async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight) {
1179
1976
  const handoff = this.generationHandoff;
1180
1977
  if (handoff) {
1181
1978
  await handoff;
@@ -1188,21 +1985,30 @@ export class LSPService {
1188
1985
  const root = await this.resolveServerRoot(server, filePath);
1189
1986
  if (!root || this.checkDestroyed())
1190
1987
  return undefined;
1988
+ if (server.role !== "auxiliary") {
1989
+ resolvedRoots?.set(server.id, normalizeMapKey(root));
1990
+ }
1191
1991
  const allowInstall = this.shouldAllowInstall(server.id);
1192
1992
  const normalizedRoot = normalizeMapKey(root);
1193
1993
  const key = `${server.id}:${normalizedRoot}`;
1194
1994
  const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
1195
1995
  if (server.availabilityKey &&
1196
1996
  isDirectLspCommandTemporarilyUnavailable(server.availabilityKey)) {
1197
- logLatency({
1198
- type: "phase",
1199
- phase: "lsp_client_skipped_unavailable_command",
1997
+ // #1743: during an outage this path runs once per file per touch,
1998
+ // so a raw write here is a per-file log storm. The ledger counts
1999
+ // every skip exactly, keyed on (command, file); only the first per
2000
+ // pair also writes the detailed record.
2001
+ emitBounded("lsp_client_skipped_unavailable_command", `${server.availabilityKey}:${normalizeMapKey(filePath)}`, {
1200
2002
  filePath,
1201
2003
  durationMs: 0,
1202
2004
  metadata: {
1203
2005
  serverId: server.id,
1204
2006
  command: server.availabilityKey,
1205
2007
  },
2008
+ }, {
2009
+ ledgerKind: "lsp-client-skipped-unavailable-command",
2010
+ risingEdgePer: "identity",
2011
+ reason: `command ${server.availabilityKey} temporarily unavailable`,
1206
2012
  });
1207
2013
  return undefined;
1208
2014
  }
@@ -1210,15 +2016,20 @@ export class LSPService {
1210
2016
  return undefined;
1211
2017
  }
1212
2018
  if (this.permanentlyBroken.has(key)) {
1213
- logLatency({
1214
- type: "phase",
1215
- phase: "lsp_client_skipped_broken",
2019
+ // #1743: same per-file-per-touch storm as the unavailable-command
2020
+ // skip above. Identity is (server, file) so a single wedged server
2021
+ // cannot hide which files it is refusing.
2022
+ emitBounded("lsp_client_skipped_broken", `${server.id}:${normalizeMapKey(filePath)}`, {
1216
2023
  filePath,
1217
2024
  durationMs: 0,
1218
2025
  metadata: {
1219
2026
  serverId: server.id,
1220
2027
  permanent: true,
1221
2028
  },
2029
+ }, {
2030
+ ledgerKind: "lsp-client-skipped-broken",
2031
+ risingEdgePer: "identity",
2032
+ reason: `${server.id} latched permanently broken`,
1222
2033
  });
1223
2034
  return undefined;
1224
2035
  }
@@ -1402,15 +2213,20 @@ export class LSPService {
1402
2213
  }
1403
2214
  const brokenUntil = this.state.broken.get(key);
1404
2215
  if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
1405
- logLatency({
1406
- type: "phase",
1407
- phase: "lsp_client_skipped_broken",
2216
+ // #1743: the breaker-cooldown sibling of the permanently-broken skip
2217
+ // above, sharing its identity so an outage produces one record per
2218
+ // (server, file) rather than one per touch.
2219
+ emitBounded("lsp_client_skipped_broken", `${server.id}:${normalizeMapKey(filePath)}`, {
1408
2220
  filePath,
1409
2221
  durationMs: 0,
1410
2222
  metadata: {
1411
2223
  serverId: server.id,
1412
2224
  retryInMs: Math.max(0, brokenUntil - Date.now()),
1413
2225
  },
2226
+ }, {
2227
+ ledgerKind: "lsp-client-skipped-broken",
2228
+ risingEdgePer: "identity",
2229
+ reason: `${server.id} in breaker cooldown`,
1414
2230
  });
1415
2231
  return undefined;
1416
2232
  }
@@ -1439,6 +2255,19 @@ export class LSPService {
1439
2255
  return undefined;
1440
2256
  spawnPromise = started.promise;
1441
2257
  }
2258
+ // Announce the in-flight spawn so the caller can skip a doomed touch
2259
+ // wait. The announcement never returns a client and never
2260
+ // short-circuits. A spawn that settles inside the race window is picked
2261
+ // up by getClientForFile, which re-reads `state.clients` at the point it
2262
+ // acts on the shortcut.
2263
+ //
2264
+ // Do NOT add a `state.clients` early return here. This point sits
2265
+ // downstream of the warm-reuse path, the dead-client shutdown, the #1127
2266
+ // give-up latch, the breaker cooldown, and the #1332 idle eviction, so a
2267
+ // return here re-publishes a client every one of those already declined.
2268
+ // It also skips the `finally` below that owns the `inFlight` entry,
2269
+ // which strands the settled promise and stops the server respawning.
2270
+ onSpawnInFlight?.(server.id);
1442
2271
  try {
1443
2272
  return await spawnPromise;
1444
2273
  }
@@ -1544,7 +2373,9 @@ export class LSPService {
1544
2373
  this.optionalFailureLogged.delete(key);
1545
2374
  }
1546
2375
  logSessionStart(`lsp spawn ${server.id}: success source=${spawned.source ?? "unknown"} (${Date.now() - startedAt}ms)`);
1547
- recordLsp(server.id, root, "spawn_success", Date.now() - startedAt);
2376
+ const spawnDurationMs = Date.now() - startedAt;
2377
+ recordLsp(server.id, root, "spawn_success", spawnDurationMs);
2378
+ recordSuccessfulLspSpawn(server.id, spawnDurationMs);
1548
2379
  if (!this.workspaceProbeLogged.has(key)) {
1549
2380
  logSessionStart(`lsp workspace-diag probe ${server.id}: advertised=${wsDiag.advertised} mode=${wsDiag.mode} provider=${wsDiag.diagnosticProviderKind}`);
1550
2381
  this.workspaceProbeLogged.add(key);
@@ -1582,9 +2413,19 @@ export class LSPService {
1582
2413
  async openFile(filePath, content, options) {
1583
2414
  if (this.checkDestroyed())
1584
2415
  return;
2416
+ // #1783: anchored before the client acquisition, for the same reason
2417
+ // touchFile anchors on its own start — see recordFullyCoveredSync.
2418
+ const startedAt = Date.now();
1585
2419
  await this.withClientForFileUse(filePath, undefined, options?.spawnBudgetMs, async (spawned) => {
1586
2420
  const languageId = getLanguageId(filePath) ?? "plaintext";
1587
2421
  await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
2422
+ // #1783: openFile is a real sync path — actionable-warnings and the
2423
+ // diagnostic-freshness callers reach a server through here and never
2424
+ // through touchFile. Without this, those documents were invisible to
2425
+ // the drift backstop. The same full-coverage gate applies, so an
2426
+ // auxiliary holding the document keeps the record unwritten rather
2427
+ // than letting one client's push claim every view is current.
2428
+ this.recordFullyCoveredSync(filePath, content, [spawned], true, startedAt);
1588
2429
  });
1589
2430
  }
1590
2431
  /**
@@ -1600,20 +2441,58 @@ export class LSPService {
1600
2441
  * and optionally collect diagnostics with explicit scope.
1601
2442
  */
1602
2443
  async touchFile(filePath, content, options = {}) {
1603
- if (this.checkDestroyed())
2444
+ if (this.checkDestroyed()) {
2445
+ // #1618: a destroyed service never reaches the language server — this
2446
+ // early return used to log nothing at all, so a workspace sweep whose
2447
+ // idle-reset timer fired mid-run left every remaining file with zero
2448
+ // trace, indistinguishable from budget exhaustion. Cheap and local: no
2449
+ // server round trip, matching the `no_clients`/`success` sibling
2450
+ // records this phase already emits below.
2451
+ const destroyedDiagnosticsMode = options.collectDiagnostics
2452
+ ? (options.diagnostics ?? "document")
2453
+ : (options.diagnostics ?? "none");
2454
+ logLatency({
2455
+ type: "phase",
2456
+ phase: "lsp_touch_file",
2457
+ filePath: normalizeMapKey(filePath),
2458
+ durationMs: 0,
2459
+ metadata: {
2460
+ serverCountAttempted: 0,
2461
+ serverCountReady: 0,
2462
+ clientScope: options.clientScope ??
2463
+ (destroyedDiagnosticsMode === "full" ? "all" : "primary"),
2464
+ diagnosticsMode: destroyedDiagnosticsMode,
2465
+ source: options.source ?? "unknown",
2466
+ failureKind: "destroyed",
2467
+ },
2468
+ });
1604
2469
  return;
2470
+ }
1605
2471
  const startedAt = Date.now();
1606
2472
  const normalizedPath = normalizeMapKey(filePath);
2473
+ // #1783: every path that asks a language server anything comes through
2474
+ // here, so this is where the disk-drift backstop gets its heartbeat.
2475
+ // Deliberately NOT awaited: the sweep is rate-limited to one pass per
2476
+ // 10s and runs alongside this touch's own client acquisition and
2477
+ // diagnostics wait, so it adds nothing to this call's latency. The
2478
+ // resync it may issue re-enters touchFile; every such re-entry happens
2479
+ // while the pass is still running, so the tracker's single-flight guard
2480
+ // returns the running pass and no recursion occurs. That guard is the
2481
+ // single mechanism — there is deliberately no second source-based check
2482
+ // here, which would be an unprovable duplicate of it.
2483
+ void this.sweepDocumentDrift().catch(() => { });
1607
2484
  const diagnosticsMode = options.collectDiagnostics
1608
2485
  ? (options.diagnostics ?? "document")
1609
2486
  : (options.diagnostics ?? "none");
1610
2487
  const source = options.source ?? "unknown";
1611
2488
  const clientScope = options.clientScope ?? (diagnosticsMode === "full" ? "all" : "primary");
1612
2489
  const useAllClients = clientScope === "all";
2490
+ const resolvedPrimaryRoots = new Map();
2491
+ const waitSkipReasons = new Set();
1613
2492
  let spawned;
1614
2493
  let serverCountAttempted;
1615
2494
  if (useAllClients) {
1616
- const result = await this.getClientsForFile(filePath, options.excludeServerIds);
2495
+ const result = await this.getClientsForFile(filePath, options.excludeServerIds, resolvedPrimaryRoots);
1617
2496
  spawned = result.clients;
1618
2497
  serverCountAttempted = result.serverCountAttempted;
1619
2498
  }
@@ -1621,14 +2500,14 @@ export class LSPService {
1621
2500
  // Primary language server + the enabled cross-cutting auxiliaries
1622
2501
  // (opengrep, …). The aggregation layer merges/dedups their diagnostics.
1623
2502
  const [entry, aux] = await Promise.all([
1624
- this.getClientForFile(filePath, options.maxClientWaitMs),
2503
+ this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons),
1625
2504
  this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? [])),
1626
2505
  ]);
1627
2506
  spawned = entry ? [entry, ...aux] : aux;
1628
2507
  serverCountAttempted = spawned.length;
1629
2508
  }
1630
2509
  else {
1631
- const entry = await this.getClientForFile(filePath, options.maxClientWaitMs);
2510
+ const entry = await this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons);
1632
2511
  spawned = entry ? [entry] : [];
1633
2512
  serverCountAttempted =
1634
2513
  spawned.length > 0
@@ -1638,6 +2517,14 @@ export class LSPService {
1638
2517
  : 0;
1639
2518
  }
1640
2519
  if (spawned.length === 0) {
2520
+ // A bounded caller can lose the client race while the single-flight spawn
2521
+ // it started is still progressing. Preserve that lifecycle evidence in the
2522
+ // touch verdict instead of reclassifying an empty ready set as absence.
2523
+ // `isSpawnInFlight` reads the spawn coordinator's own state and filters to
2524
+ // primary candidates, so this stays coupled to the dedupe mechanism.
2525
+ const failureKind = this.isSpawnInFlight(filePath, resolvedPrimaryRoots)
2526
+ ? "spawn_in_flight_budget_elapsed"
2527
+ : "no_clients_none_spawning";
1641
2528
  logLatency({
1642
2529
  type: "phase",
1643
2530
  phase: "lsp_touch_file",
@@ -1650,7 +2537,10 @@ export class LSPService {
1650
2537
  diagnosticsMode,
1651
2538
  source,
1652
2539
  maxClientWaitMs: options.maxClientWaitMs,
1653
- failureKind: "no_clients",
2540
+ failureKind,
2541
+ ...(waitSkipReasons.size > 0
2542
+ ? { reason: [...waitSkipReasons][0] }
2543
+ : {}),
1654
2544
  },
1655
2545
  });
1656
2546
  return;
@@ -1698,7 +2588,100 @@ export class LSPService {
1698
2588
  // no-new-version baseline below.
1699
2589
  const notifySkippedServerIds = new Set(spawnedServerIds.filter((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId)));
1700
2590
  const notifySkipped = spawned.length > 0 && notifySkippedServerIds.size === spawned.length;
1701
- const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
2591
+ // #1531: the pre-notify diagnostics baseline for THIS file on each client.
2592
+ // It used to be `client.diagnosticsVersion`, a client-GLOBAL counter that also
2593
+ // advances for files this touch never mentions — which let a sibling file's
2594
+ // publication both end this file's wait early and read as an answer for it.
2595
+ // `getDiagnosticsVersionForPath` returns that same counter's value as of this
2596
+ // file's last publication, so every comparison downstream stays on one axis
2597
+ // while ignoring sibling paths. Captured here because the notify below clears
2598
+ // each client's cache for the file.
2599
+ //
2600
+ // The accessor is REQUIRED on `LSPClient`, so a real client always answers
2601
+ // with a number. The optional call is only so a hand-written test double that
2602
+ // predates it fails CLOSED — `undefined` keeps the existing "no usable
2603
+ // baseline" branch below and can never satisfy the evidence check — instead of
2604
+ // quietly reverting to the global counter, which is the defect itself.
2605
+ const readPathVersion = (client) => client.getDiagnosticsVersionForPath?.(filePath);
2606
+ const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, readPathVersion(entry.client)]));
2607
+ // #1458: read a late auxiliary publication BEFORE the ordinary resync
2608
+ // clears its client cache. Carry it only when the publication's exact
2609
+ // sent-content fingerprint matches this touch's content. A changed edit,
2610
+ // version-less publication, or malformed binding fails closed and is not
2611
+ // replayed. The fresh notify still runs below, so scanners continue toward
2612
+ // a publication for this touch while the prior late result reaches the read.
2613
+ const touchContentHash = this.hashContent(content);
2614
+ // #1586: THE content-match atom. Every content-bound question in this touch
2615
+ // — the carry-over below, #1493's pre-notify snapshot, and the merge-time
2616
+ // coverage predicate — asks it here and nowhere else, so a door cannot
2617
+ // acquire a rule of its own by writing the comparison inline. A binding with
2618
+ // no `contentHash` (version-less publish) fails closed: `undefined` never
2619
+ // equals a hash.
2620
+ const bindingMatchesTouchContent = (binding) => binding?.contentHash === touchContentHash;
2621
+ const carriedAuxiliary = options.collectDiagnostics
2622
+ ? spawned.flatMap((entry) => {
2623
+ if (entry.info.role !== "auxiliary")
2624
+ return [];
2625
+ const binding = entry.client.getDiagnosticBinding?.(filePath);
2626
+ if (!bindingMatchesTouchContent(binding))
2627
+ return [];
2628
+ const diags = entry.client.getDiagnostics(filePath);
2629
+ return diags.length > 0 ? [{ diags, binding }] : [];
2630
+ })
2631
+ : [];
2632
+ // #1493: auxiliaries whose STORED publication already covers exactly the
2633
+ // bytes this touch carries. Read BEFORE the notify below, which clears each
2634
+ // client's cache for the file. Unlike `carriedAuxiliary` this does not
2635
+ // require findings: an empty publication bound to this content is evidence
2636
+ // the scanner reported, which is what keeps a genuinely clean file clean
2637
+ // when its wait produces nothing new (a debounce-skipped notify, or a late
2638
+ // publication carried in from the previous touch).
2639
+ const auxPublishedThisContent = new Set(spawned.flatMap((entry) => entry.info.role === "auxiliary" &&
2640
+ bindingMatchesTouchContent(entry.client.getDiagnosticBinding?.(filePath))
2641
+ ? [entry.info.id]
2642
+ : []));
2643
+ const spawnedByServerId = new Map(spawned.map((entry) => [entry.info.id, entry]));
2644
+ // #1549/#1586: does this auxiliary's publication describe exactly the bytes
2645
+ // this touch carries? THE coverage predicate — every door reads it, so a
2646
+ // scanner can never be named uncovered while its findings ride along in
2647
+ // `.diags`, or the reverse.
2648
+ //
2649
+ // It UNIONS two content-bound reads rather than replacing one with the other,
2650
+ // because they answer different questions:
2651
+ //
2652
+ // - `auxPublishedThisContent` was captured BEFORE the notify (#1493), because
2653
+ // a landed write clears the cache and would erase the evidence that the
2654
+ // scanner had already reported on these bytes.
2655
+ // - the read below is LIVE, and it catches the opposite race — #1459's own
2656
+ // documented signature: a write charged as timed out, or one the fan-out
2657
+ // gate deferred behind, that LANDS LATE, after which the scanner publishes
2658
+ // for this touch's content. Judging that auxiliary on the pre-notify
2659
+ // snapshot alone drops its CURRENT findings and names it uncovered — an
2660
+ // underclaim about a scanner that answered.
2661
+ //
2662
+ // Either match means covered; both are content-bound, so neither can pass off
2663
+ // another revision's findings as this touch's answer.
2664
+ //
2665
+ // WHEN it is asked is part of the rule. The live half moves over the life of
2666
+ // a touch, so two doors that ask at two instants can disagree — and the merge
2667
+ // ACTS on its answer by dropping findings, which a later answer cannot undo.
2668
+ // Every door that shares the merge's consequences therefore reads ONE frozen
2669
+ // evaluation (`auxCoveredAtMerge`, below), taken immediately before the merge
2670
+ // and never re-asked afterwards. The only callers of this function are that
2671
+ // freeze and the two aux wait-outcome producers, whose rows describe their own
2672
+ // instant and are reconciled against the freeze before anything is claimed.
2673
+ //
2674
+ // Everything it cannot speak for fails CLOSED — an id that never reached
2675
+ // `spawned` (a breaker-skipped scanner, which never attached) and any
2676
+ // primary-role server, whose findings are governed by #570's
2677
+ // timeout-preserves-last-known semantics rather than by this exemption.
2678
+ const auxCoversThisContent = (serverId) => {
2679
+ const entry = spawnedByServerId.get(serverId);
2680
+ if (entry?.info.role !== "auxiliary")
2681
+ return false;
2682
+ return (auxPublishedThisContent.has(serverId) ||
2683
+ bindingMatchesTouchContent(entry.client.getDiagnosticBinding?.(filePath)));
2684
+ };
1702
2685
  // #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
1703
2686
  // write gets its OWN notifyWriteBudgetMs budget rather than one shared
1704
2687
  // deadline over a single Promise.all — otherwise one backpressured server
@@ -1713,8 +2696,22 @@ export class LSPService {
1713
2696
  // server timed out"; this list carries the per-server detail the
1714
2697
  // demonstratedReady gate reads so a healthy sibling stays eligible.
1715
2698
  const notifyWriteTimedOutServerIds = [];
2699
+ // #1459: auxiliaries whose resync was DEFERRED because the gate already had
2700
+ // one outstanding write for that server. They carry no evidence about this
2701
+ // content, so they join the coverage gap below.
2702
+ const notifyDeferredServerIds = [];
1716
2703
  if (!notifySkipped) {
1717
2704
  const budget = notifyWriteBudgetMs();
2705
+ // #1459: how long a queued auxiliary may wait for its resync slot. Bounded
2706
+ // by the write budget AND by whatever the caller already declared it is
2707
+ // willing to spend on this touch (`maxClientWaitMs` — cascade's cold
2708
+ // snapshot passes 1000ms), minus what the client wait above already spent.
2709
+ // A flat write-budget wait would tax a caller that asked for less than one
2710
+ // budget in total. Non-positive means "no time left to queue": the server
2711
+ // is reported as uncovered immediately.
2712
+ const queueWaitMs = options.maxClientWaitMs !== undefined
2713
+ ? Math.min(budget, Math.max(0, options.maxClientWaitMs - (Date.now() - startedAt)))
2714
+ : budget;
1718
2715
  await Promise.all(spawned.map(async (entry) => {
1719
2716
  // #743: this server already has this content from a recent touch
1720
2717
  // that landed. Pushing again would clear its diagnostic cache for
@@ -1724,12 +2721,95 @@ export class LSPService {
1724
2721
  return;
1725
2722
  // Same identity as the broken/demonstratedReady maps.
1726
2723
  const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
2724
+ // #1459: one outstanding resync per auxiliary. Primaries are
2725
+ // untouched — they serve one file per touch and are not the fan-out
2726
+ // target a `clientScope: "all"` sweep floods.
2727
+ const gated = entry.info.role === "auxiliary" && clientKey !== undefined;
2728
+ let slot;
2729
+ if (gated && clientKey) {
2730
+ // #1714: before taking the slot, make the server prove it
2731
+ // processed the notifies already sent. A sweep is sequential, so
2732
+ // the slot gate below is almost always free and cannot see a
2733
+ // backlog building. This never refuses the write — a scanner that
2734
+ // will not answer is latched past and left to #743's stall
2735
+ // machinery, which can demote and respawn it.
2736
+ const barrierStartedAt = Date.now();
2737
+ await this.paceAuxNotify(clientKey, entry, filePath, queueWaitMs, {
2738
+ source,
2739
+ clientScope,
2740
+ });
2741
+ // The barrier spends from the SAME budget the caller granted, so
2742
+ // the slot wait gets only what is left. Otherwise a paced touch
2743
+ // could cost two full budgets.
2744
+ const slotWaitMs = Math.max(0, queueWaitMs - (Date.now() - barrierStartedAt));
2745
+ const claim = await this.claimAuxNotifySlot(clientKey, entry, filePath, slotWaitMs);
2746
+ if ("outstandingMs" in claim) {
2747
+ // Queued behind a write the scanner has not accepted inside our
2748
+ // budget. Pushing anyway is what floods it, so this touch reports
2749
+ // the scanner as uncovered instead. The wedge timer armed with the
2750
+ // blocking write is what demotes a dead input path.
2751
+ notifyDeferredServerIds.push(entry.info.id);
2752
+ logLatency({
2753
+ type: "phase",
2754
+ phase: "lsp_notify_resync_deferred",
2755
+ filePath: normalizedPath,
2756
+ durationMs: claim.outstandingMs,
2757
+ metadata: {
2758
+ serverId: entry.info.id,
2759
+ source,
2760
+ clientScope,
2761
+ reason: "outstanding_write",
2762
+ outstandingMs: claim.outstandingMs,
2763
+ queueWaitMs,
2764
+ },
2765
+ });
2766
+ return;
2767
+ }
2768
+ slot = claim;
2769
+ }
1727
2770
  let wrote;
1728
2771
  let rejected = false;
1729
2772
  try {
1730
- wrote = await withDeadline(entry.client.notify
2773
+ const writeStartedAt = Date.now();
2774
+ // Constructed inside the try so a client double without `notify`
2775
+ // (or any synchronous throw) still reads as a rejected write rather
2776
+ // than rejecting the whole per-file `Promise.all`.
2777
+ const writePromise = entry.client.notify
1731
2778
  .open(filePath, content, languageId, undefined, silent)
1732
- .then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
2779
+ .then(() => true);
2780
+ // #1714: the document is now in this auxiliary's input queue,
2781
+ // whether or not the write settles inside our budget. Counted here
2782
+ // so the next file sees the real backlog.
2783
+ if (gated && clientKey) {
2784
+ this.noteAuxNotifyIssued(clientKey, entry.client);
2785
+ }
2786
+ if (slot && clientKey) {
2787
+ const client = entry.client;
2788
+ const release = slot.release;
2789
+ // Release the slot on the write's OWN settle, whatever the caller
2790
+ // below decided to wait for. The handle is identity-checked, so a
2791
+ // demotion (which clears the map) or a later claim cannot be
2792
+ // released by this one.
2793
+ void writePromise.then(() => {
2794
+ release();
2795
+ // The write landed, just not inside the caller's budget —
2796
+ // retract the timeout it was charged for. A write that landed
2797
+ // IN budget took the success path below, which clears the
2798
+ // streak outright, so only the late case retracts. A landing
2799
+ // past the WEDGE window keeps its strike: at that point the
2800
+ // stall was long enough that #743's demotion is the honest
2801
+ // verdict, not a latency artifact.
2802
+ const outstandingMs = Date.now() - writeStartedAt;
2803
+ if (outstandingMs > budget && outstandingMs <= notifyWedgedMs()) {
2804
+ this.retractNotifyWriteBackpressure(clientKey, entry.info.id, filePath, outstandingMs, client);
2805
+ }
2806
+ }, release);
2807
+ }
2808
+ wrote = await withDeadline(writePromise, {
2809
+ ms: budget,
2810
+ onTimeout: "undefined",
2811
+ onReject: "propagate",
2812
+ });
1733
2813
  }
1734
2814
  catch {
1735
2815
  // The write itself rejected (not backpressure): the content did
@@ -1737,6 +2817,10 @@ export class LSPService {
1737
2817
  // rejection is not a stdin-backpressure signal and must not count
1738
2818
  // toward the backpressure demotion streak.
1739
2819
  rejected = true;
2820
+ // A synchronous throw (a client double without `notify`) never
2821
+ // reached the settle handlers that release the slot — release it
2822
+ // here so one bad client cannot wedge the queue. Idempotent.
2823
+ slot?.release();
1740
2824
  }
1741
2825
  if (wrote === true) {
1742
2826
  // A clean write clears any accrued backpressure streak (#743).
@@ -1757,6 +2841,25 @@ export class LSPService {
1757
2841
  }
1758
2842
  }
1759
2843
  }));
2844
+ // #1783: stamp the disk-drift record only when the touch achieved FULL
2845
+ // coverage — every targeted server's write landed AND no other live
2846
+ // client holds this document. The debounce entry above is per-server, so
2847
+ // stamping a per-FILE record inside that loop claimed a coverage the
2848
+ // touch may not have had: a primary-scoped touch leaves an auxiliary's
2849
+ // view untouched, and a touch where one server times out leaves that
2850
+ // server behind. Either way the sweep would then read "in sync" and stop
2851
+ // looking. On a partial touch the PREVIOUS record is deliberately kept:
2852
+ // its older `syncedAt` and older fingerprint keep the document eligible,
2853
+ // so the next sweep re-pushes it at full scope instead of going blind.
2854
+ //
2855
+ // BOTH exit lists, not just the timed-out one. The #1459 gate defers an
2856
+ // auxiliary whose previous write is still outstanding, and that server
2857
+ // leaves the write loop early without ever joining
2858
+ // `notifyWriteTimedOutServerIds`. Reading only that list let a deferred
2859
+ // scanner's untouched view be stamped as covered — the same laundering
2860
+ // through a different door.
2861
+ this.recordFullyCoveredSync(filePath, content, spawned, notifyWriteTimedOutServerIds.length === 0 &&
2862
+ notifyDeferredServerIds.length === 0, startedAt);
1760
2863
  if (notifyWriteTimedOutServerIds.length > 0) {
1761
2864
  logLatency({
1762
2865
  type: "phase",
@@ -1772,14 +2875,50 @@ export class LSPService {
1772
2875
  });
1773
2876
  }
1774
2877
  }
1775
- // File-level flag: at least one server's write timed out (kept for the
1776
- // conservative touch-wide `inconclusive` merge semantics — see below).
2878
+ // File-level flag: at least one server's write timed out. Kept as the
2879
+ // observability summary (`lsp_touch_file.notifyWriteTimedOut`); the
2880
+ // `inconclusive` verdict reads the PRIMARY-scoped flag below (#1549).
1777
2881
  const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
2882
+ // #1549: the honesty verdict is decided from the PRIMARY population only.
2883
+ // An auxiliary that missed a deadline is a named coverage gap, never an
2884
+ // inconclusive touch — see `resolveTouchVerdict` (diagnostic-binding.ts).
2885
+ const primaryEntries = spawned.filter((entry) => entry.info.role !== "auxiliary");
2886
+ const primaryServerIds = new Set(primaryEntries.map((e) => e.info.id));
2887
+ const primaryNotifyWriteTimedOutServerIds = notifyWriteTimedOutServerIds.filter((id) => primaryServerIds.has(id));
2888
+ const primaryNotifyWriteTimedOut = primaryNotifyWriteTimedOutServerIds.length > 0;
2889
+ // #1459: read by the diagnostics wait and the merge below — a deferred
2890
+ // server is neither waited on nor read from.
2891
+ const deferredResyncServerIds = new Set(notifyDeferredServerIds);
1778
2892
  let diagnosticsTimedOut = false;
2893
+ // #1549: every server (any role) that produced no publication evidence when
2894
+ // the diagnostics wait lapsed. Read three ways: the primary members decide
2895
+ // `diagnosticsTimedOut` and are the `inconclusiveServerIds` attribution, the
2896
+ // auxiliary members join the coverage gap, and no member may be marked
2897
+ // `demonstratedReady`. Empty when the wait did not lapse.
2898
+ let diagnosticsUnansweredServerIds = [];
2899
+ // #1549: the primary subset of the list above, captured when the wait lapsed
2900
+ // so the attribution survives the silent-clean/sync gates clearing the flag.
2901
+ let diagnosticsUnansweredPrimaryServerIds = [];
2902
+ // #1549: a gate that certifies silence AS the answer (the tsserver sync
2903
+ // confirm, either silent-clean gate) retracts the primary attribution with it —
2904
+ // those servers answered, in the only way their capabilities allow, so they
2905
+ // stay eligible for `demonstratedReady` exactly as before this change.
2906
+ // Auxiliary members are left in place: nothing certified them, and they are
2907
+ // what the coverage gap reports.
2908
+ const retractPrimaryTimeoutAttribution = () => {
2909
+ diagnosticsUnansweredPrimaryServerIds = [];
2910
+ diagnosticsUnansweredServerIds = diagnosticsUnansweredServerIds.filter((id) => !primaryServerIds.has(id));
2911
+ };
1779
2912
  // R8 (#714): server ids of aux-role servers whose push wait was cut off by
1780
2913
  // the aux grace window. Undefined when no aux was cut off (primary-only
1781
2914
  // paths never set this). Logged in lsp_touch_file metadata.
1782
2915
  let auxCutOffServerIds;
2916
+ // #1493: aux-role servers this touch carries NO evidence from — the cut-off
2917
+ // set above PLUS the ones that stayed silent through their own budget with
2918
+ // no stored publication for this content. This is what narrows the
2919
+ // confirmation; `auxCutOffServerIds` stays cut_off-only so the R8 latency
2920
+ // field keeps its original meaning.
2921
+ let auxUnconfirmedServerIds;
1783
2922
  // #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
1784
2923
  // full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
1785
2924
  // holds the sync commands' answer when the racing confirm won the wait
@@ -1827,7 +2966,7 @@ export class LSPService {
1827
2966
  const sweepFirstTouch = new Map();
1828
2967
  if (options.sweepIndexGate) {
1829
2968
  for (const entry of spawned) {
1830
- const strategy = getStrategy(entry.client.serverId);
2969
+ const strategy = getStrategy(entry.client.serverId, entry.client.getLaunchVariant?.());
1831
2970
  if (strategy.workspaceIndexing) {
1832
2971
  sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
1833
2972
  }
@@ -1848,7 +2987,7 @@ export class LSPService {
1848
2987
  const silentCleanWarmupServers = new Set();
1849
2988
  if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
1850
2989
  for (const entry of spawned) {
1851
- const strategy = getStrategy(entry.client.serverId);
2990
+ const strategy = getStrategy(entry.client.serverId, entry.client.getLaunchVariant?.());
1852
2991
  if (strategy.workspaceIndexing !== true)
1853
2992
  continue;
1854
2993
  try {
@@ -1877,7 +3016,8 @@ export class LSPService {
1877
3016
  // slow aux (opengrep) can't override the per-edit cap. Resolves as soon
1878
3017
  // as a server publishes; this is just its individual deadline. (#242)
1879
3018
  const perServerTimeout = (serverId) => {
1880
- const strategy = getStrategy(serverId);
3019
+ const launchVariant = spawned.find((entry) => entry.client.serverId === serverId)?.client.getLaunchVariant?.();
3020
+ const strategy = getStrategy(serverId, launchVariant);
1881
3021
  let strategyWait = strategy.aggregateWaitMs;
1882
3022
  // #645: a `workspaceIndexing` server (marksman) only needs the
1883
3023
  // full budget for the FIRST same-sweep touch to it — every
@@ -1933,7 +3073,13 @@ export class LSPService {
1933
3073
  timeoutFor = () => callerCap ?? modeFloor;
1934
3074
  }
1935
3075
  // Detection deadline = the slowest individual server's budget.
1936
- const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
3076
+ // #1459: computed over the servers actually WAITED ON. A deferred server
3077
+ // contributes no wait, so including its (typically longest) scanner budget
3078
+ // here would raise the aggregate threshold above anything that can elapse
3079
+ // and mask a real timeout on the servers that did wait.
3080
+ const timeoutMs = Math.max(0, ...spawned
3081
+ .filter((e) => !deferredResyncServerIds.has(e.info.id))
3082
+ .map((e) => timeoutFor(e.client.serverId)));
1937
3083
  // #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
1938
3084
  // starts. Cheap synchronous gates first (notify succeeded, collecting,
1939
3085
  // primary scope, `serverId === "typescript"` — the sync commands this
@@ -1951,7 +3097,7 @@ export class LSPService {
1951
3097
  clientScope === "primary" &&
1952
3098
  spawned.length === 1 &&
1953
3099
  spawned[0].client.serverId === "typescript" &&
1954
- getStrategy(spawned[0].client.serverId).silentOnClean === true) {
3100
+ getStrategy(spawned[0].client.serverId, spawned[0].client.getLaunchVariant?.()).silentOnClean === true) {
1955
3101
  try {
1956
3102
  const snapshots = await this.getCapabilitySnapshots(filePath);
1957
3103
  tsserverSyncEligible =
@@ -1979,7 +3125,17 @@ export class LSPService {
1979
3125
  // The #707 tsserver sync race operates exclusively on single-server
1980
3126
  // primary-scope touches (guarded by `clientScope === "primary" &&
1981
3127
  // spawned.length === 1`), so there is NO interaction with this path.
3128
+ // #1458 S4: also gated on `collectDiagnostics` — a non-collecting
3129
+ // with-auxiliary touch has nothing to carry the aux wait's result
3130
+ // INTO (its diagnostics are discarded either way), so paying up to
3131
+ // `auxCeilingMs` of extra latency for it buys nothing. Both current
3132
+ // callers (`getDiagnostics`'s with-auxiliary path and the cascade's
3133
+ // collecting touch) already pass `collectDiagnostics: true`, so this
3134
+ // is latent-today defense, not a behavior change — but a future
3135
+ // non-collecting with-auxiliary caller must not silently inherit the
3136
+ // full aux-grace cost for diagnostics it's about to throw away.
1982
3137
  const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
3138
+ options.collectDiagnostics === true &&
1983
3139
  spawned.some((e) => e.info.role === "auxiliary");
1984
3140
  // Per-server wait promises (each already bounded by its own
1985
3141
  // perServerTimeout — unchanged from before R8).
@@ -1993,21 +3149,51 @@ export class LSPService {
1993
3149
  }
1994
3150
  }
1995
3151
  const perServerWaits = spawned.map((entry) => {
3152
+ // #1459: a DEFERRED server never received this content, so its version
3153
+ // can never advance past the baseline — waiting on it burns its whole
3154
+ // budget and would flip the touch to `inconclusive`, discarding a
3155
+ // primary answer that IS trustworthy. It contributes no wait; the
3156
+ // coverage gap below is what reports its absence.
3157
+ if (deferredResyncServerIds.has(entry.info.id)) {
3158
+ return Promise.resolve(undefined);
3159
+ }
1996
3160
  const serverTimeout = timeoutFor(entry.client.serverId);
3161
+ // #1531: a per-path baseline. `clientWaitForDiagnostics` compares it
3162
+ // against this path's own publication stamp, so a sibling file's
3163
+ // publication on a shared client can no longer end this wait before the
3164
+ // server's own budget lapses — which is what kept the outcome labels
3165
+ // honest (`cut_off` means our grace won, `silent` means the server's own
3166
+ // budget lapsed with nothing published).
1997
3167
  const baseline = diagnosticBaselines.get(entry.client);
1998
3168
  const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
3169
+ // #1639: `ensureWarmForSweep`'s readiness probe (`source:
3170
+ // "lsp_sweep_warmup"`, `collectDiagnostics: false`) runs a real pull
3171
+ // round trip on this same file, then the sweep's real touch follows
3172
+ // immediately after — two legitimate settle observations for one
3173
+ // file, not a duplicate. Tag the warm-up one distinctly so a
3174
+ // consumer can tell them apart instead of double-counting. Omitted
3175
+ // (rather than passed as "pull") on the common path — the client
3176
+ // already defaults to "pull", and existing tests assert the exact
3177
+ // argument list `waitForDiagnostics` is called with.
3178
+ const isWarmupTouch = source === "lsp_sweep_warmup";
1999
3179
  // #743: per-server — a server we DID push to still gets the
2000
3180
  // version-baseline wait even when a sibling was debounced away.
2001
3181
  const wait = !notifySkippedServerIds.has(entry.info.id) && Number.isFinite(baseline)
2002
3182
  ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
2003
3183
  minVersion: baseline,
2004
3184
  ...(pullOnly && { pullOnly: true }),
3185
+ ...(isWarmupTouch && { pullSettleSource: "pull-warmup" }),
2005
3186
  })
2006
3187
  : pullOnly
2007
3188
  ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
2008
3189
  pullOnly: true,
3190
+ ...(isWarmupTouch && { pullSettleSource: "pull-warmup" }),
2009
3191
  })
2010
- : entry.client.waitForDiagnostics(filePath, serverTimeout);
3192
+ : isWarmupTouch
3193
+ ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
3194
+ pullSettleSource: "pull-warmup",
3195
+ })
3196
+ : entry.client.waitForDiagnostics(filePath, serverTimeout);
2011
3197
  return wait.catch(() => undefined);
2012
3198
  });
2013
3199
  // The push wait — same per-server budget composition as before #707;
@@ -2017,49 +3203,137 @@ export class LSPService {
2017
3203
  ? (() => {
2018
3204
  // Primary waits: all non-auxiliary servers.
2019
3205
  const primaryWaits = perServerWaits.filter((_, i) => spawned[i].info.role !== "auxiliary");
2020
- // Aux waits: auxiliary servers (advisory).
3206
+ // Aux waits: auxiliary servers (advisory). `client` and the
3207
+ // pre-notify `diagnosticsVersion` baseline travel alongside the
3208
+ // promise so the outcome can be decided from EVIDENCE after the
3209
+ // race, not from how the raced promise settled (#1458 S1 — see
3210
+ // below).
2021
3211
  const auxWaits = perServerWaits
2022
3212
  .map((p, i) => spawned[i].info.role === "auxiliary"
2023
- ? { promise: p, serverId: spawned[i].info.id }
3213
+ ? {
3214
+ promise: p,
3215
+ serverId: spawned[i].info.id,
3216
+ client: spawned[i].client,
3217
+ baseline: diagnosticBaselines.get(spawned[i].client),
3218
+ }
2024
3219
  : null)
2025
3220
  .filter((x) => x !== null);
2026
- const auxGraceMs = readEnvAuxGraceMs() ?? 500;
2027
- // After all primaries settle, give auxiliaries at most auxGraceMs.
2028
- // Late aux results are dropped from the wait (advisory only — they
2029
- // land in the client cache and surface on the next edit); aux servers
2030
- // that did answer within the grace are included automatically since
3221
+ const auxCeilingMs = readEnvAuxGraceMs() ?? 2000;
3222
+ // After all primaries settle, give each auxiliary the smaller of
3223
+ // its declared wait budget and the global auxiliary ceiling. The
3224
+ // 2000ms default admits measured ~1.3s warm scanner runs without
3225
+ // making every edit pay opengrep's 3500ms cold-start allowance.
3226
+ // Late aux results are dropped from this wait. A later unchanged-
3227
+ // content read may carry a SHA-256-bound cache publication before its
3228
+ // resync clears the cache; changed or unknown content never replays.
3229
+ // Aux servers that answer within the grace are included automatically since
2031
3230
  // their waitForDiagnostics already resolved. The cut-off server ids
2032
3231
  // are logged in the latency metadata (lsp_touch_file phase, field
2033
3232
  // `auxCutOffServerIds`).
2034
3233
  return Promise.all(primaryWaits).then(async () => {
2035
3234
  if (auxWaits.length === 0)
2036
3235
  return;
2037
- const auxTimeout = new Promise((resolve) => {
2038
- const t = setTimeout(() => resolve("timeout"), auxGraceMs);
2039
- if (typeof t === "object" && "unref" in t)
2040
- t.unref?.();
3236
+ const auxWaitStartedAt = Date.now();
3237
+ const outcomes = await Promise.all(auxWaits.map(async (aux) => {
3238
+ const budgetMs = Math.min(timeoutFor(aux.serverId), auxCeilingMs);
3239
+ let timer;
3240
+ const timeout = new Promise((resolve) => {
3241
+ timer = setTimeout(() => resolve(false), budgetMs);
3242
+ if (typeof timer === "object" && "unref" in timer) {
3243
+ timer.unref?.();
3244
+ }
3245
+ });
3246
+ const raced = await Promise.race([
3247
+ aux.promise.then(() => true),
3248
+ timeout,
3249
+ ]);
3250
+ if (timer)
3251
+ clearTimeout(timer);
3252
+ // #1458 S1: `waitForDiagnostics` RESOLVES on its own timeout
3253
+ // (client.ts) — it never rejects, and a silent scanner that
3254
+ // published nothing looks identical, promise-wise, to one
3255
+ // that answered. `raced === true` only means "the promise
3256
+ // settled before our timer fired"; it is not proof anything
3257
+ // was published. Decide the outcome from evidence instead:
3258
+ // did this aux's `diagnosticsVersion` advance past the
3259
+ // pre-notify baseline captured before the wait started?
3260
+ // - raced === false → "cut_off" (our timer won;
3261
+ // the aux's own wait never got to answer for itself).
3262
+ // - raced === true, no evidence → "silent" (the aux's own
3263
+ // wait gave up within its budget with nothing to report —
3264
+ // NOT the same as having answered).
3265
+ // - raced === true, evidence → "answered" (a fresh
3266
+ // publication actually landed for this touch).
3267
+ //
3268
+ // #1531: the evidence is read PER PATH. The global
3269
+ // `diagnosticsVersion` advances for every file this client
3270
+ // publishes, so a concurrent touch of an unrelated file used to
3271
+ // hand this one an unearned "answered" row. The per-path stamp
3272
+ // carries the global counter's value at store time, so the
3273
+ // comparison stays monotonic across cache evictions while
3274
+ // ignoring sibling paths — and it is the SAME axis `baseline`
3275
+ // was captured on above.
3276
+ const currentPathVersion = readPathVersion(aux.client);
3277
+ const publishedEvidence = raced &&
3278
+ Number.isFinite(aux.baseline) &&
3279
+ currentPathVersion !== undefined &&
3280
+ currentPathVersion > aux.baseline;
3281
+ // #1459: a DEFERRED aux was never sent this content and is not
3282
+ // waited on at all, so its instantly-resolved placeholder
3283
+ // promise must not read as "silent". "Silent" is the reserved
3284
+ // signal for a scanner that HAD the content, finished inside
3285
+ // its own budget, and published nothing (#1493) — recording a
3286
+ // deferral there would corrupt the one row that tracks it.
3287
+ const outcome = deferredResyncServerIds.has(aux.serverId)
3288
+ ? "deferred"
3289
+ : !raced
3290
+ ? "cut_off"
3291
+ : publishedEvidence
3292
+ ? "answered"
3293
+ : "silent";
3294
+ return {
3295
+ serverId: aux.serverId,
3296
+ outcome,
3297
+ // #1493: carried into the coverage-gap policy so a silent
3298
+ // auxiliary that already published for these exact bytes is
3299
+ // not demoted. Logged too — it is the reason a `silent` row
3300
+ // did not narrow the touch.
3301
+ // #1586: through the one predicate, so this row and the merge
3302
+ // below cannot disagree about the same scanner.
3303
+ publishedThisContent: auxCoversThisContent(aux.serverId),
3304
+ budgetMs,
3305
+ elapsedMs: Date.now() - auxWaitStartedAt,
3306
+ // #1458 S3: elapsed measured from BEFORE the primary wait
3307
+ // (waitStartedAt), not just from auxWaitStartedAt — this is
3308
+ // what lets a latency row validate the ~1.3s warm-scanner
3309
+ // figure the 2000ms ceiling was set from; `elapsedMs` alone
3310
+ // only covers the POST-primary aux phase.
3311
+ elapsedSinceNotifyMs: Date.now() - waitStartedAt,
3312
+ };
3313
+ }));
3314
+ const unfinished = outcomes
3315
+ .filter((outcome) => outcome.outcome === "cut_off")
3316
+ .map((outcome) => outcome.serverId);
3317
+ if (unfinished.length > 0)
3318
+ auxCutOffServerIds = unfinished;
3319
+ // #1493: one policy over both no-answer shapes. Lives in
3320
+ // diagnostic-binding.ts so no consumer re-derives the rule from
3321
+ // an outcome string.
3322
+ const uncovered = auxiliaryCoverageGap(outcomes);
3323
+ if (uncovered.length > 0)
3324
+ auxUnconfirmedServerIds = uncovered;
3325
+ logLatency({
3326
+ type: "phase",
3327
+ phase: "lsp_aux_wait_outcome",
3328
+ filePath: normalizedPath,
3329
+ durationMs: Date.now() - auxWaitStartedAt,
3330
+ // #1533: `waitShape` names the producer, because the aggregate
3331
+ // path emits the same row with the same outcome vocabulary
3332
+ // minus `cut_off`. A field query that sees only `silent` rows
3333
+ // must be able to tell "our ceiling was in play" from "the
3334
+ // auxiliary's own full budget lapsed".
3335
+ metadata: { clientScope, waitShape: "aux_grace", outcomes },
2041
3336
  });
2042
- const auxAll = Promise.all(auxWaits.map((a) => a.promise)).then(() => "done");
2043
- const outcome = await Promise.race([auxAll, auxTimeout]);
2044
- if (outcome === "timeout") {
2045
- // Record which auxiliaries did NOT finish in time.
2046
- const unfinished = [];
2047
- for (const a of auxWaits) {
2048
- let done = false;
2049
- // Check synchronously if already resolved by racing against
2050
- // a resolved promise.
2051
- await Promise.race([
2052
- a.promise.then(() => {
2053
- done = true;
2054
- }),
2055
- Promise.resolve(),
2056
- ]);
2057
- if (!done)
2058
- unfinished.push(a.serverId);
2059
- }
2060
- if (unfinished.length > 0)
2061
- auxCutOffServerIds = unfinished;
2062
- }
2063
3337
  });
2064
3338
  })()
2065
3339
  : Promise.all(perServerWaits).then(() => { });
@@ -2134,6 +3408,109 @@ export class LSPService {
2134
3408
  await pushWait;
2135
3409
  }
2136
3410
  const waitedMs = Date.now() - waitStartedAt;
3411
+ // #1533: the same auxiliary coverage evidence for a collecting touch that
3412
+ // did NOT enter the aux-grace wait — in practice `clientScope: "all"`, the
3413
+ // batch/directory scan surface. Auxiliaries ARE spawned on that scope
3414
+ // (`getClientsForFile` returns every matching server, #573) and each one is
3415
+ // waited on inside `Promise.all(perServerWaits)` on its own per-server
3416
+ // budget, but `hasTouchAuxiliaries` is `with-auxiliary`-only, so no evidence
3417
+ // was ever derived and a silent scanner aggregated as an unqualified
3418
+ // `"confirmed"` — the #1493 false clean surviving on a different scope.
3419
+ //
3420
+ // NO SECOND WAIT. Every aux promise here has already settled (the
3421
+ // `Promise.all` above awaited it), so this reads post-wait state only. That
3422
+ // is deliberate: #1459's resync gate exists to ABSORB the aux fan-out of an
3423
+ // "all"-scope sweep into deferrals, and entering a per-neighbour aux grace
3424
+ // here would pay back the latency that gate just recovered. The evidence is
3425
+ // free; only the verdict changes.
3426
+ //
3427
+ // WHICH verdicts change, stated without overreach. Where the auxiliary's
3428
+ // budget is the MAX over waited servers (`perServerTimeout` is
3429
+ // `min(callerCap, strategyWait)` per server, `timeoutMs` is the max across
3430
+ // them), a silent auxiliary already tripped `diagnosticsTimedOut` and the
3431
+ // touch was already `inconclusive` — which is decided BEFORE the coverage
3432
+ // gap, so those results are unchanged. That covers opengrep on every current
3433
+ // per-edit path, whose 3500 exceeds either cap. But a FASTER auxiliary beside
3434
+ // a slower primary (typos 1500 or ast-grep 1800 next to rust-analyzer 3000
3435
+ // under a 2000 cap) settles inside `timeoutMs`, so nothing timed out and this
3436
+ // block genuinely narrows a result that used to read `confirmed`. That is the
3437
+ // fix working: the scanner said nothing about these bytes. It is fail-safe —
3438
+ // the primary's findings still ride along and only the coverage claim is
3439
+ // withdrawn — and the cost is a skipped cache seed for that file. Both cases
3440
+ // are pinned in `tests/clients/lsp/service-aux-grace.test.ts`.
3441
+ //
3442
+ // `cut_off` cannot arise on this path — there is no grace timer to end a
3443
+ // wait early — so the shapes are `answered` / `silent` / `deferred`, decided
3444
+ // by exactly the rules the grace path uses (#1458 S1: a settled promise is
3445
+ // not proof of a publication; only a `diagnosticsVersion` advance past the
3446
+ // pre-notify baseline is). `waitShape` distinguishes the two producers in
3447
+ // field data, since a `silent` row here means the auxiliary's own full
3448
+ // per-server budget lapsed rather than our ceiling cutting it short.
3449
+ //
3450
+ // A server the caller EXCLUDED (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`, #584)
3451
+ // never reaches `spawned`, so it cannot be reported here — an excluded-by-
3452
+ // design scanner is a routing decision, not a coverage gap, exactly as
3453
+ // `brokenSkippedAuxiliaryServerIds` already treats it.
3454
+ //
3455
+ // Written as `!hasTouchAuxiliaries` rather than `clientScope === "all"` so a
3456
+ // future scope that spawns auxiliaries without entering the grace wait fails
3457
+ // closed here by default instead of needing to be remembered. The #707
3458
+ // tsserver sync race can reach here with `pushWait` still pending, but it is
3459
+ // gated on `clientScope === "primary" && spawned.length === 1`, which spawns
3460
+ // no auxiliaries at all — so the per-role filter below is empty and no
3461
+ // evidence is read before its wait ends.
3462
+ //
3463
+ // `elapsedMs` and `elapsedSinceNotifyMs` are equal by construction here:
3464
+ // there is no separate post-primary aux phase to measure, so both describe
3465
+ // the one aggregate wait. Both fields are kept so a query can read either
3466
+ // producer's rows without special-casing the schema.
3467
+ //
3468
+ // The evidence is read PER PATH, through the same `readPathVersion` accessor
3469
+ // the grace path uses (#1531, landed on master while this was in review).
3470
+ // This is NOT interchangeable with `client.diagnosticsVersion`: that global
3471
+ // counter also advances for files this touch never mentions, so two
3472
+ // CONCURRENT touches sharing one auxiliary client cross-satisfy — a
3473
+ // publication for a.ts hands b.ts an unearned `answered`. That matters
3474
+ // especially here, because the highest-frequency `"all"` caller (the cascade
3475
+ // neighbour fan-out in `clients/dispatch/integration.ts`) is a
3476
+ // `Promise.allSettled` and its touches are always concurrent. Reading the
3477
+ // per-path stamp keeps this comparison on the SAME axis `baseline` was
3478
+ // captured on, and `undefined` from a double that predates the accessor fails
3479
+ // CLOSED rather than silently reverting to the global counter.
3480
+ if (!hasTouchAuxiliaries && options.collectDiagnostics === true) {
3481
+ const auxEntries = spawned.filter((entry) => entry.info.role === "auxiliary");
3482
+ if (auxEntries.length > 0) {
3483
+ const outcomes = auxEntries.map((entry) => {
3484
+ const baseline = diagnosticBaselines.get(entry.client);
3485
+ const currentPathVersion = readPathVersion(entry.client);
3486
+ const publishedEvidence = Number.isFinite(baseline) &&
3487
+ currentPathVersion !== undefined &&
3488
+ currentPathVersion > baseline;
3489
+ return {
3490
+ serverId: entry.info.id,
3491
+ outcome: deferredResyncServerIds.has(entry.info.id)
3492
+ ? "deferred"
3493
+ : publishedEvidence
3494
+ ? "answered"
3495
+ : "silent",
3496
+ publishedThisContent: auxCoversThisContent(entry.info.id),
3497
+ budgetMs: timeoutFor(entry.client.serverId),
3498
+ elapsedMs: waitedMs,
3499
+ elapsedSinceNotifyMs: waitedMs,
3500
+ };
3501
+ });
3502
+ const uncovered = auxiliaryCoverageGap(outcomes);
3503
+ if (uncovered.length > 0)
3504
+ auxUnconfirmedServerIds = uncovered;
3505
+ logLatency({
3506
+ type: "phase",
3507
+ phase: "lsp_aux_wait_outcome",
3508
+ filePath: normalizedPath,
3509
+ durationMs: waitedMs,
3510
+ metadata: { clientScope, waitShape: "aggregate", outcomes },
3511
+ });
3512
+ }
3513
+ }
2137
3514
  if (tsserverSyncConfirmed !== undefined) {
2138
3515
  // #707: the racing sync confirm won — a definitive answer well under
2139
3516
  // the push-wait budget. Not a timeout, not inconclusive.
@@ -2158,8 +3535,59 @@ export class LSPService {
2158
3535
  // Within ~20 ms of the configured budget we treat it as a timeout;
2159
3536
  // the LSP didn't beat the cap. Diagnostics that arrive late still
2160
3537
  // land in the client's cache and surface on the next edit.
2161
- diagnosticsTimedOut = true;
2162
- for (const entry of spawned) {
3538
+ //
3539
+ // #1549: WHOSE budget lapsed decides the verdict. `timeoutMs` is the MAX
3540
+ // over the servers waited on, so a slow auxiliary (opengrep declares
3541
+ // 3500ms) sets the aggregate deadline for the whole touch — and a
3542
+ // touch-wide `diagnosticsTimedOut = true` then discarded a primary answer
3543
+ // that landed in 100ms. Attribute the lapse per server instead: the
3544
+ // touch is inconclusive only when a PRIMARY produced no evidence; an
3545
+ // auxiliary that produced none becomes a named coverage gap below.
3546
+ //
3547
+ // Evidence, not promise settlement (#1458 S1): `waitForDiagnostics`
3548
+ // resolves on its own timeout, so a settled wait proves nothing. The
3549
+ // per-path publication stamp advancing past this touch's pre-notify
3550
+ // baseline (#1531) is the primary signal; a present per-file cache entry
3551
+ // is the second, because the notify this touch just sent cleared that
3552
+ // entry (`clearDiagnosticsForPath`), so a present one can only be a fresh
3553
+ // answer — the same signal #814's aggregate gate already trusts.
3554
+ //
3555
+ // Every unknown fails CLOSED: a client that exposes neither accessor
3556
+ // reads as unanswered, which for a primary is exactly the pre-#1549
3557
+ // verdict. This block can therefore only ever NARROW an inconclusive
3558
+ // touch, never create one.
3559
+ const answeredForThisTouch = (entry) => {
3560
+ const baseline = diagnosticBaselines.get(entry.client);
3561
+ const currentPathVersion = readPathVersion(entry.client);
3562
+ if (Number.isFinite(baseline) &&
3563
+ currentPathVersion !== undefined &&
3564
+ currentPathVersion > baseline) {
3565
+ return true;
3566
+ }
3567
+ try {
3568
+ return entry.client.getAllDiagnostics?.().has(normalizedPath) === true;
3569
+ }
3570
+ catch {
3571
+ // Fail closed: an unreadable cache is not evidence of an answer.
3572
+ return false;
3573
+ }
3574
+ };
3575
+ // A deferred server was never sent this content and is not waited on, so
3576
+ // it cannot have "timed out" — it is already reported as a coverage gap.
3577
+ const waited = spawned.filter((entry) => !deferredResyncServerIds.has(entry.info.id));
3578
+ const unanswered = waited.filter((entry) => !answeredForThisTouch(entry));
3579
+ diagnosticsUnansweredServerIds = unanswered.map((e) => e.info.id);
3580
+ diagnosticsUnansweredPrimaryServerIds = unanswered
3581
+ .filter((entry) => entry.info.role !== "auxiliary")
3582
+ .map((e) => e.info.id);
3583
+ // Fail-safe: a touch with no waited-on primary has no primary answer to
3584
+ // preserve, so it keeps the pre-#1549 touch-wide verdict rather than
3585
+ // absolving itself on an auxiliary's evidence.
3586
+ const hasWaitedPrimary = waited.some((entry) => entry.info.role !== "auxiliary");
3587
+ diagnosticsTimedOut =
3588
+ !hasWaitedPrimary ||
3589
+ diagnosticsUnansweredPrimaryServerIds.length > 0;
3590
+ for (const entry of unanswered) {
2163
3591
  incrementDegradationCount({
2164
3592
  kind: "lsp-diagnostics-timeout",
2165
3593
  // `info.id` is the authoritative server identity carried by
@@ -2178,9 +3606,22 @@ export class LSPService {
2178
3606
  durationMs: waitedMs,
2179
3607
  metadata: {
2180
3608
  source,
3609
+ // #1444: WHICH server(s) burned the budget — without this the
3610
+ // ~221/day timeout rows can't be attributed to a server at all.
3611
+ // `info.id` (not `client.serverId`) for the same reason the
3612
+ // degradation ledger above uses it: test doubles and lightweight
3613
+ // clients need not expose `serverId`.
3614
+ serverIds: spawned.map((e) => e.info.id),
2181
3615
  clientScope,
2182
3616
  diagnosticsMode,
2183
3617
  timeoutMs,
3618
+ // #1549: which of those servers actually produced no evidence, and
3619
+ // whether the lapse is attributable to a primary (the touch is
3620
+ // inconclusive) or only to auxiliaries (a named coverage gap, with
3621
+ // the primary's findings intact). Without these two fields a
3622
+ // forensic sweep cannot tell the two apart at all.
3623
+ unansweredServerIds: diagnosticsUnansweredServerIds,
3624
+ attributedToPrimary: diagnosticsTimedOut,
2184
3625
  },
2185
3626
  });
2186
3627
  }
@@ -2217,9 +3658,20 @@ export class LSPService {
2217
3658
  // must have actually landed" conservatism #799 established — a
2218
3659
  // server's silence is only evidence of "clean" when we know it saw
2219
3660
  // the new content.
2220
- if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
3661
+ //
3662
+ // #1549: both the gate and its "still outstanding" set are PRIMARY-scoped.
3663
+ // An auxiliary is never asked to prove itself tier3-silent here, because an
3664
+ // auxiliary that never reported is already named as a coverage gap — and
3665
+ // requiring it to was the second half of the touch-wide conflation: a clean
3666
+ // markdown file whose marksman silence IS the answer stayed inconclusive
3667
+ // purely because an opengrep scan beside it had not finished. The auxiliary's
3668
+ // absence still costs the touch its full confirmation (`partial`); what it no
3669
+ // longer does is erase the primary's answer.
3670
+ if (diagnosticsTimedOut &&
3671
+ !primaryNotifyWriteTimedOut &&
3672
+ clientScope === "all") {
2221
3673
  try {
2222
- const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
3674
+ const outstanding = primaryEntries.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
2223
3675
  !entry.client.getAllDiagnostics().has(normalizedPath));
2224
3676
  if (outstanding.length > 0) {
2225
3677
  const snapshots = await this.getCapabilitySnapshots(filePath);
@@ -2238,6 +3690,7 @@ export class LSPService {
2238
3690
  const liveness = await Promise.all(outstanding.map((entry) => (entry.client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false)));
2239
3691
  if (liveness.every(Boolean)) {
2240
3692
  diagnosticsTimedOut = false;
3693
+ retractPrimaryTimeoutAttribution(); // #1549
2241
3694
  logLatency({
2242
3695
  type: "phase",
2243
3696
  phase: "lsp_silent_clean_confirm",
@@ -2261,6 +3714,55 @@ export class LSPService {
2261
3714
  }
2262
3715
  }
2263
3716
  }
3717
+ // #1586: THE coverage evaluation, taken ONCE, here — the last statement before
3718
+ // the merge, with no `await` between it and the drop it authorizes. Everything
3719
+ // that shares the merge's consequences reads this frozen set and never asks
3720
+ // the live predicate again.
3721
+ //
3722
+ // The review round on this change proved why the freeze has to be the unit.
3723
+ // `touchFile` awaits after the merge — `brokenSkippedAuxiliaryServerIds` on
3724
+ // every collecting touch, the tsserver sync and liveness gates on theirs — and
3725
+ // a publication landing in that window flips the live predicate. Re-asking it
3726
+ // when the coverage gap was named then un-named a scanner whose findings the
3727
+ // merge had ALREADY dropped: `.diags` missing the scanner's answer while the
3728
+ // touch claimed `confirmed`, which unblocks the `lastKnownDiagnostics` prime
3729
+ // and the `demonstratedReady` mark that `coverageGap` exists to hold shut.
3730
+ // That is #1459's blackout reading as scanned-clean — the overclaim direction,
3731
+ // and the worse one. A drop is an action; a later answer cannot undo it, so
3732
+ // the naming must be settled from the same instant that authorized it.
3733
+ const auxCoveredAtMerge = new Set(spawned
3734
+ .filter((entry) => auxCoversThisContent(entry.info.id))
3735
+ .map((entry) => entry.info.id));
3736
+ // Which DEFERRED auxiliaries this touch genuinely carries no evidence from.
3737
+ // The deferral itself only proves the gate did not send these bytes on THIS
3738
+ // touch; whether the scanner has reported on them is a content-hash question.
3739
+ const uncoveredDeferredServerIds = notifyDeferredServerIds.filter((serverId) => !auxCoveredAtMerge.has(serverId));
3740
+ // An AUXILIARY whose notify write never landed still holds the previous
3741
+ // content's findings — nothing cleared its cache — and before this change that
3742
+ // touch was blanket `inconclusive`, so no consumer read the merged array. Now
3743
+ // the primary's answer flows, which means the auxiliary's stale findings would
3744
+ // flow with it and be reported (with the previous revision's line numbers) as
3745
+ // this touch's answer. Drop them; the write failure is reported as a coverage
3746
+ // gap instead.
3747
+ // Auxiliaries only. A PRIMARY keeps #570's deliberate
3748
+ // timeout-preserves-last-known-diagnostics semantics, and its write failure
3749
+ // makes the touch inconclusive anyway, so no consumer reads the array as
3750
+ // current.
3751
+ const staleWriteAuxiliaryServerIds = spawned
3752
+ .filter((entry) => entry.info.role === "auxiliary" &&
3753
+ notifyWriteTimedOutServerIds.includes(entry.info.id) &&
3754
+ !auxCoveredAtMerge.has(entry.info.id))
3755
+ .map((entry) => entry.info.id);
3756
+ // #1586: every contribution this merge withholds, in one set. The merged
3757
+ // BINDING reads it too (#1459's door, which filtered the raw deferral set and
3758
+ // so excluded the fingerprint of a deferred-but-covered scanner whose findings
3759
+ // the merge had just kept) — a dropped contributor must lose its findings and
3760
+ // its binding together, or the merged `boundToCurrentDisk` describes bytes the
3761
+ // result no longer contains.
3762
+ const droppedAuxiliaryServerIds = new Set([
3763
+ ...uncoveredDeferredServerIds,
3764
+ ...staleWriteAuxiliaryServerIds,
3765
+ ]);
2264
3766
  // #707: when the racing sync confirm won the wait, its answer IS the
2265
3767
  // collected result — the file's real syntactic + semantic state straight
2266
3768
  // from tsserver (clean = [], dirty = real findings that a silentOnClean
@@ -2269,7 +3771,20 @@ export class LSPService {
2269
3771
  let collected = options.collectDiagnostics
2270
3772
  ? tsserverSyncConfirmed !== undefined
2271
3773
  ? mergeLspDiagnostics(tsserverSyncConfirmed)
2272
- : mergeLspDiagnostics(spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)))
3774
+ : mergeLspDiagnostics([
3775
+ // #1459: a DEFERRED server's cache still holds the PREVIOUS
3776
+ // content's findings — the resync that would have cleared it never
3777
+ // ran. Merging them would report another revision's findings (and
3778
+ // its line numbers) as this touch's answer, the one hazard the
3779
+ // gate itself creates. Drop them; the gap is reported instead.
3780
+ // #1586: unless the scanner has since published for exactly these
3781
+ // bytes — `droppedAuxiliaryServerIds` is the one frozen answer the
3782
+ // coverage naming and the merged binding read too.
3783
+ ...spawned.flatMap((entry) => droppedAuxiliaryServerIds.has(entry.info.id)
3784
+ ? []
3785
+ : entry.client.getDiagnostics(filePath)),
3786
+ ...carriedAuxiliary.flatMap((entry) => entry.diags),
3787
+ ])
2273
3788
  : undefined;
2274
3789
  // #1095 (P3-b): whether `collected` came from a tsserver sync confirm
2275
3790
  // (`tsserverSyncRequest`) rather than the publish cache. A sync-confirmed
@@ -2305,6 +3820,7 @@ export class LSPService {
2305
3820
  // Sync answered — confirmed result (clean or with diagnostics).
2306
3821
  // Clear the timed-out flag so the touch is no longer inconclusive.
2307
3822
  diagnosticsTimedOut = false;
3823
+ retractPrimaryTimeoutAttribution(); // #1549
2308
3824
  syncConfirmed = true;
2309
3825
  collected = syncResult.length > 0
2310
3826
  ? mergeLspDiagnostics(syncResult)
@@ -2363,11 +3879,14 @@ export class LSPService {
2363
3879
  // into the general one once both have soaked, but that's a separate,
2364
3880
  // lower-risk follow-up, not bundled into this fix.
2365
3881
  if (diagnosticsTimedOut &&
2366
- !notifyWriteTimedOut &&
3882
+ // #1549: primary-scoped, like the aggregate gate. `spawned.length === 1`
3883
+ // below means the one server IS the primary, so this is the same condition
3884
+ // written in the vocabulary the rest of the merge now uses.
3885
+ !primaryNotifyWriteTimedOut &&
2367
3886
  !tsserverSyncEligible &&
2368
3887
  clientScope === "primary" &&
2369
3888
  spawned.length === 1 &&
2370
- getStrategy(spawned[0].client.serverId).silentOnClean === true) {
3889
+ getStrategy(spawned[0].client.serverId, spawned[0].client.getLaunchVariant?.()).silentOnClean === true) {
2371
3890
  try {
2372
3891
  const snapshots = await this.getCapabilitySnapshots(filePath);
2373
3892
  if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
@@ -2380,6 +3899,7 @@ export class LSPService {
2380
3899
  const alive = await (spawned[0].client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false);
2381
3900
  if (alive) {
2382
3901
  diagnosticsTimedOut = false;
3902
+ retractPrimaryTimeoutAttribution(); // #1549
2383
3903
  if (collected !== undefined)
2384
3904
  collected = mergeLspDiagnostics([]);
2385
3905
  logLatency({
@@ -2402,16 +3922,138 @@ export class LSPService {
2402
3922
  // behavior.
2403
3923
  }
2404
3924
  }
2405
- // A touch is inconclusive when EITHER the notify write or the
2406
- // diagnostics wait hit their deadline for ANY of the spawned servers
2407
- // (these flags are touch-wide, covering the whole `Promise.all` over
2408
- // `spawned` — see the field doc on the return type). We deliberately
2409
- // err toward caution here: `collected` merges diagnostics across every
2410
- // spawned server, so even a partial timeout (e.g. a slow auxiliary
2411
- // while the primary answered) means the merged result may be missing
2412
- // findings that just hadn't arrived yet — it must not be trusted as a
2413
- // confirmed answer.
2414
- const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
3925
+ // #1549: a touch is inconclusive when a PRIMARY's notify write or the
3926
+ // diagnostics wait hit their deadline. Both inputs are primary-scoped now.
3927
+ //
3928
+ // The rule this replaced — `notifyWriteTimedOut || diagnosticsTimedOut`, both
3929
+ // flags touch-wide over every spawned server — discarded every good answer in
3930
+ // the touch whenever one auxiliary was slow. `timeoutMs` is the MAX over the
3931
+ // servers waited on, so opengrep's 3500ms budget set the deadline for the whole
3932
+ // touch and a typescript answer that landed in 100ms read as "nothing is known
3933
+ // about this file". Measured over 6,079 cascade neighbour sweeps: 97.6%
3934
+ // inconclusive, against 15% for ordinary edit-time touches in the same window.
3935
+ //
3936
+ // The caution the old comment argued for is preserved, in the honest place: the
3937
+ // merged result IS missing whatever the unreporting auxiliary would have said,
3938
+ // so the touch withdraws its claim of full coverage (`confirmation: "partial"`
3939
+ // plus `unconfirmedServerIds`, below) and every consumer that treats
3940
+ // confirmation as proof of coverage still fails closed. What it no longer does
3941
+ // is throw away the primary's answer, which is the #533 honesty doctrine
3942
+ // cutting both ways.
3943
+ const verdict = resolveTouchVerdict({
3944
+ primaryNotifyWriteTimedOutServerIds,
3945
+ diagnosticsTimedOut,
3946
+ diagnosticsUnansweredServerIds: diagnosticsUnansweredPrimaryServerIds,
3947
+ });
3948
+ const inconclusive = verdict.inconclusive;
3949
+ // #1470/#1493: an auxiliary whose push wait was CUT OFF by the aux grace
3950
+ // timer (R8/#714) contributed exactly as much evidence about this file as one
3951
+ // that went silent inside its own budget — none. Both now narrow the
3952
+ // confirmation, through the one `auxiliaryCoverageGap` policy. A hung or
3953
+ // silent opengrep used to resolve `confirmation: "confirmed"` and read as
3954
+ // confirmed-clean on the security lane; the silent half survived #1470
3955
+ // because it only tripped `diagnosticsTimedOut` when it was the ONLY
3956
+ // auxiliary, so a fast sibling hid it (#1493).
3957
+ // This does NOT flip the touch to inconclusive: that would discard a
3958
+ // primary answer that IS trustworthy (#533 honesty doctrine cuts both ways —
3959
+ // overclaiming and underclaiming are both dishonest). Instead the confirmation
3960
+ // is NARROWED: `"partial"`, naming the servers it does not speak for, so every
3961
+ // consumer that treats confirmation as proof of coverage fails closed while
3962
+ // the primary's findings still flow.
3963
+ // #1459: two more doors into the same room, and they open BEFORE any wait —
3964
+ // so `auxiliaryCoverageGap` (which reads wait outcomes) cannot see either on
3965
+ // its own. A scanner whose breaker was open never attached, and one whose
3966
+ // resync the fan-out gate deferred never received this content.
3967
+ //
3968
+ // The deferred ids are unioned in rather than left to the aux-wait policy on
3969
+ // purpose: an aux outcome row requires an auxiliary to have been SPAWNED, and a
3970
+ // breaker-skipped scanner never was. #1533: an `"all"`-scope sweep emits outcome
3971
+ // rows now too, so a spawned-but-deferred server arrives through BOTH routes as
3972
+ // outcome `"deferred"` — where a stored publication
3973
+ // for these exact bytes can still exempt it — so the Set dedups rather than
3974
+ // double-reports, and #1493's content-hash exemption is not bypassed here:
3975
+ // a deferred aux is only unioned in because the gate itself proves it was
3976
+ // never sent this content.
3977
+ const brokenSkippedServerIds = collected !== undefined
3978
+ ? await this.brokenSkippedAuxiliaryServerIds(filePath, clientScope, options, spawned)
3979
+ : [];
3980
+ // #1549: the fourth door, and the one this issue opened. An auxiliary that
3981
+ // missed a deadline no longer makes the touch inconclusive, so it MUST arrive
3982
+ // here instead — otherwise the fix would launder a scanner blackout into a
3983
+ // clean bill of health, which is the #1459/#1493 false-clean pointing the other
3984
+ // way. Two shapes reach this and no aux outcome row: an auxiliary whose notify
3985
+ // write timed out or rejected, and one that produced no publication evidence
3986
+ // when the wait lapsed (including on a NON-collecting touch, which derives no
3987
+ // outcome rows at all). The content-bound exemption is honored through the same
3988
+ // `auxCoversThisContent` predicate the merge uses — one rule, so a scanner
3989
+ // cannot be named uncovered while its findings ride along in `.diags`, or the
3990
+ // reverse. The Set dedups against the ids `auxiliaryCoverageGap` reported.
3991
+ const auxNoAnswerServerIds = spawned
3992
+ .filter((entry) => entry.info.role === "auxiliary" &&
3993
+ (diagnosticsUnansweredServerIds.includes(entry.info.id) ||
3994
+ notifyWriteTimedOutServerIds.includes(entry.info.id)) &&
3995
+ !auxCoveredAtMerge.has(entry.info.id))
3996
+ .map((entry) => entry.info.id);
3997
+ // #1586: whatever the doors contributed, the RESULT's coverage claim is
3998
+ // settled from the MERGE's frozen evaluation — never a fresh one, which is
3999
+ // what made this an overclaim in review (see `auxCoveredAtMerge`). The
4000
+ // per-door filters above are not redundant with this one: they shape the
4001
+ // `lsp_scanner_coverage_gap` fields, which each answer "what did THIS door
4002
+ // see", while this settles "what does the touch speak for". It matters most
4003
+ // for `auxUnconfirmedServerIds`, decided when the aux wait ended and therefore
4004
+ // strictly BEFORE the merge — reconciling it here is what keeps a scanner from
4005
+ // being named while the merge kept its findings. A breaker-skipped scanner
4006
+ // never reached `spawned`, so it is not in the covered set and stays named:
4007
+ // fail closed.
4008
+ const unconfirmedServerIds = [
4009
+ ...new Set([
4010
+ ...(auxUnconfirmedServerIds ?? []),
4011
+ ...auxNoAnswerServerIds,
4012
+ ...uncoveredDeferredServerIds,
4013
+ ...brokenSkippedServerIds,
4014
+ ]),
4015
+ ].filter((serverId) => !auxCoveredAtMerge.has(serverId));
4016
+ const coverageGap = unconfirmedServerIds.length > 0;
4017
+ // The record that proves a blackout is no longer read as clean: one row per
4018
+ // touch that a scanner did not cover, naming the scanner and the reason.
4019
+ if (brokenSkippedServerIds.length > 0 ||
4020
+ uncoveredDeferredServerIds.length > 0 ||
4021
+ // #1549: the auxiliary deadline misses that used to surface as a blanket
4022
+ // `inconclusive` need their own row now that the touch reports usable
4023
+ // findings — otherwise the fix would remove the only record of the blackout.
4024
+ auxNoAnswerServerIds.length > 0) {
4025
+ for (const serverId of unconfirmedServerIds) {
4026
+ const reasons = [
4027
+ brokenSkippedServerIds.includes(serverId) && "breaker skip",
4028
+ uncoveredDeferredServerIds.includes(serverId) && "deferred resync",
4029
+ auxNoAnswerServerIds.includes(serverId) && "no diagnostics answer",
4030
+ ].filter(Boolean);
4031
+ incrementDegradationCount({
4032
+ kind: "lsp-scanner-coverage-gap",
4033
+ subject: `${serverId}:${normalizedPath}`,
4034
+ reason: reasons.join(", ") || "scanner coverage gap",
4035
+ });
4036
+ }
4037
+ logLatency({
4038
+ type: "phase",
4039
+ phase: "lsp_scanner_coverage_gap",
4040
+ filePath: normalizedPath,
4041
+ durationMs: Date.now() - startedAt,
4042
+ metadata: {
4043
+ source,
4044
+ clientScope,
4045
+ ...(brokenSkippedServerIds.length > 0 && { brokenSkippedServerIds }),
4046
+ // #1586: the deferrals this touch is actually uncovered for. The raw
4047
+ // gate action keeps its own record in `lsp_notify_resync_deferred`;
4048
+ // this row exists to prove a blackout, and a scanner already bound to
4049
+ // these bytes is not one.
4050
+ ...(uncoveredDeferredServerIds.length > 0 && {
4051
+ deferredResyncServerIds: uncoveredDeferredServerIds,
4052
+ }),
4053
+ ...(auxNoAnswerServerIds.length > 0 && { auxNoAnswerServerIds }),
4054
+ },
4055
+ });
4056
+ }
2415
4057
  // #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
2416
4058
  // "actually warm" signal `ensureWarmForSweep` waits for — mark every
2417
4059
  // spawned server so a later sweep in this session sees the check as a
@@ -2422,11 +4064,38 @@ export class LSPService {
2422
4064
  // write stalled must still be eligible, so only servers whose OWN write
2423
4065
  // timed out are skipped here (rather than gating the whole loop on the
2424
4066
  // file-level `inconclusive`).
4067
+ //
4068
+ // #1470/#1493: same per-server reasoning for an auxiliary that contributed
4069
+ // no evidence. "Demonstrated ready" means this server answered for this
4070
+ // file; an auxiliary our grace timer cut off, or one that stayed silent
4071
+ // through its own budget, demonstrably did not.
4072
+ //
4073
+ // NO TEST PINS THIS LINE, and that is a property of today's readers rather
4074
+ // than a coverage gap: `ensureWarmForSweep` filters `role === "auxiliary"`
4075
+ // out of its server list entirely, so no reader consumes an auxiliary's
4076
+ // `demonstratedReady` mark and deleting this `continue` changes no observable
4077
+ // behavior (verified by mutation — the LSP suite stays green). It stays
4078
+ // because the mark's meaning is "this server answered", and the moment any
4079
+ // reader stops filtering auxiliaries out, marking a cut-off scanner warm
4080
+ // would let it skip a warm-up it never earned.
4081
+ //
4082
+ // #1549: `diagnosticsTimedOut` is primary-attributed now, so this loop can be
4083
+ // reached with an auxiliary that never answered — including on a NON-COLLECTING
4084
+ // touch, which derives no aux wait-outcome rows. `unconfirmedServerIds` covers
4085
+ // that case because `auxNoAnswerServerIds` is computed for every touch, not only
4086
+ // a collecting one; an unheard scanner is therefore already excluded here and
4087
+ // needs no separate guard. A primary cannot reach this loop unheard at all: it
4088
+ // would have set `diagnosticsTimedOut`, and the gates that clear that flag
4089
+ // retract its attribution precisely because they certified its silence AS the
4090
+ // answer (`retractPrimaryTimeoutAttribution`).
2425
4091
  const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
4092
+ const uncoveredServerIds = new Set(unconfirmedServerIds);
2426
4093
  if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
2427
4094
  for (const entry of spawned) {
2428
4095
  if (notifyTimedOutServerIds.has(entry.info.id))
2429
4096
  continue;
4097
+ if (uncoveredServerIds.has(entry.info.id))
4098
+ continue;
2430
4099
  const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
2431
4100
  if (key)
2432
4101
  this.markDemonstratedReadyKey(key);
@@ -2441,7 +4110,14 @@ export class LSPService {
2441
4110
  // empty `collected` must never erase a previously-confirmed non-empty
2442
4111
  // record (that's the #570 bug — a timeout silently reporting as clean
2443
4112
  // and wiping out known-good diagnostic state).
2444
- if (collected !== undefined && !inconclusive) {
4113
+ // #1470/#1493: a PARTIAL touch is the same hazard wearing a different flag.
4114
+ // Its merged array is missing whatever the unreporting auxiliary would have said, so
4115
+ // priming the cache with it would let `actionable-warnings`' hash-guarded
4116
+ // read replay a partially-covered result as an authoritative observation —
4117
+ // and an empty one would DELETE a previously-confirmed record on the strength
4118
+ // of a scanner that never answered. Skip the prime; the next read pays a real
4119
+ // round trip instead of trusting an incomplete one.
4120
+ if (collected !== undefined && !inconclusive && !coverageGap) {
2445
4121
  const normalizedKey = normalizeMapKey(filePath);
2446
4122
  if (collected.length > 0) {
2447
4123
  this.lastKnownDiagnostics.set(normalizedKey, collected);
@@ -2464,6 +4140,25 @@ export class LSPService {
2464
4140
  const result = { diags: collected ?? [] };
2465
4141
  if (collected !== undefined && inconclusive) {
2466
4142
  result.inconclusive = true;
4143
+ // #1549: name the primary that produced the verdict and which deadline it
4144
+ // missed, so a forensic sweep reads the cause instead of inferring it from
4145
+ // duration histograms. Absent ids on an inconclusive touch mean the
4146
+ // attribution was not derivable (a client with no per-path publication
4147
+ // stamp) — honest, and the same fail-closed verdict as before.
4148
+ if (verdict.inconclusiveServerIds) {
4149
+ result.inconclusiveServerIds = verdict.inconclusiveServerIds;
4150
+ }
4151
+ result.inconclusiveReason = verdict.inconclusiveReason;
4152
+ }
4153
+ else if (collected !== undefined && coverageGap) {
4154
+ // #1470/#1493: narrowed, not collapsed. Reached for EITHER no-answer
4155
+ // shape — a cut-off auxiliary or a silent one with nothing published for
4156
+ // this content. The primary's findings ride along in `.diags` exactly as
4157
+ // before; what changes is that the touch now states which servers it does
4158
+ // not speak for, so no consumer can read this as a full clean bill of
4159
+ // health.
4160
+ result.confirmation = "partial";
4161
+ result.unconfirmedServerIds = [...unconfirmedServerIds];
2467
4162
  }
2468
4163
  else if (collected !== undefined) {
2469
4164
  // Preserve the lower-level affirmative result across consumers. In
@@ -2488,7 +4183,20 @@ export class LSPService {
2488
4183
  // Optional-chain so a client without the getter (test doubles, a
2489
4184
  // partially-mocked client) yields "unknown" rather than throwing —
2490
4185
  // unknown preserves pre-#1095 behavior for that contributor.
2491
- spawned.map((entry) => entry.client.getDiagnosticBinding?.(filePath)));
4186
+ [
4187
+ // #1459/#1549: a contributor whose findings the merge DROPPED must
4188
+ // not decide the merged verdict either — its binding describes
4189
+ // bytes this result no longer contains.
4190
+ // #1586: read off the same frozen set the drop used. Filtering the
4191
+ // raw deferral set instead excluded the fingerprint of a
4192
+ // deferred-but-COVERED scanner whose findings the merge had just
4193
+ // kept, which diverges whenever the primary is version-less and
4194
+ // that scanner is the only contributor with a fingerprint.
4195
+ ...spawned
4196
+ .filter((entry) => !droppedAuxiliaryServerIds.has(entry.info.id))
4197
+ .map((entry) => entry.client.getDiagnosticBinding?.(filePath)),
4198
+ ...carriedAuxiliary.map((entry) => entry.binding),
4199
+ ]);
2492
4200
  result.binding = binding;
2493
4201
  }
2494
4202
  // The recent-touches entries are recorded per server inside the notify-write
@@ -2527,11 +4235,54 @@ export class LSPService {
2527
4235
  }),
2528
4236
  diagnosticsTimedOut,
2529
4237
  inconclusive,
4238
+ // #1549: the attribution the issue's observability contract asks for —
4239
+ // WHICH primary made the touch inconclusive and which deadline it missed
4240
+ // (`notify-write` vs `diagnostics-wait`, or `mixed`). Absent on a
4241
+ // conclusive touch, which blames nobody.
4242
+ ...(verdict.inconclusiveServerIds && {
4243
+ inconclusiveServerIds: verdict.inconclusiveServerIds,
4244
+ }),
4245
+ ...(verdict.inconclusiveReason && {
4246
+ inconclusiveReason: verdict.inconclusiveReason,
4247
+ }),
4248
+ // #1470: the touch's own honesty verdict, so a `cut_off` row in
4249
+ // `lsp_aux_wait_outcome` can be joined to the touch that produced it and
4250
+ // shown NOT to have claimed confirmation for that server's coverage.
4251
+ // Absent for a non-collecting touch, which claims nothing either way.
4252
+ ...(result.confirmation !== undefined && {
4253
+ confirmation: result.confirmation,
4254
+ }),
2530
4255
  // R8 (#714): server ids of auxiliaries whose push wait was cut off by
2531
4256
  // the aux grace window (primary settled clean + aux timed out in grace).
2532
4257
  // Absent when no aux was cut off. These servers' diagnostics are
2533
4258
  // advisory-only and will surface on the next edit from their cache.
2534
4259
  ...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
4260
+ // #1493: the full set the confirmation was narrowed on — the cut-off
4261
+ // ids plus every auxiliary that stayed silent with nothing published
4262
+ // for this content. Absent when the touch speaks for every server.
4263
+ // This is the join key for the issue's observability contract: a
4264
+ // `silent` row in `lsp_aux_wait_outcome` must appear here, on a touch
4265
+ // whose `confirmation` is `"partial"`.
4266
+ ...(auxUnconfirmedServerIds !== undefined && {
4267
+ auxUnconfirmedServerIds,
4268
+ }),
4269
+ // #1459: scanners this touch does not speak for because their breaker
4270
+ // was open, or because the resync gate deferred their write. Separate
4271
+ // fields because these two doors open BEFORE any wait, so neither can
4272
+ // appear in an `lsp_aux_wait_outcome` row on the sweep path. Absent
4273
+ // when every configured scanner got this content — #1586 included: a
4274
+ // deferred scanner already bound to these bytes is covered, so it is not
4275
+ // named here either. The gate's own action is recorded regardless, in
4276
+ // `lsp_notify_resync_deferred`.
4277
+ ...(brokenSkippedServerIds.length > 0 && { brokenSkippedServerIds }),
4278
+ ...(uncoveredDeferredServerIds.length > 0 && {
4279
+ deferredResyncServerIds: uncoveredDeferredServerIds,
4280
+ }),
4281
+ // #1549: auxiliaries whose own deadline lapsed — the wait produced no
4282
+ // publication, or the notify write never landed. Distinct from the fields
4283
+ // above (a different door) and from `auxUnconfirmedServerIds` (derived
4284
+ // from aux wait-outcome rows, which a non-collecting touch never emits).
4285
+ ...(auxNoAnswerServerIds.length > 0 && { auxNoAnswerServerIds }),
2535
4286
  },
2536
4287
  });
2537
4288
  return result;
@@ -2624,7 +4375,8 @@ export class LSPService {
2624
4375
  }
2625
4376
  const clientWaits = spawned.map(async (entry) => {
2626
4377
  const waitStart = Date.now();
2627
- const strategy = getStrategy(entry.info.id);
4378
+ const launchVariant = entry.client.getLaunchVariant?.();
4379
+ const strategy = getStrategy(entry.info.id, launchVariant);
2628
4380
  await entry.client.waitForDiagnostics(filePath, strategy.aggregateWaitMs);
2629
4381
  let diagnostics = entry.client.getDiagnostics(filePath);
2630
4382
  const firstWaitMs = Date.now() - waitStart;
@@ -2636,6 +4388,7 @@ export class LSPService {
2636
4388
  }
2637
4389
  return {
2638
4390
  serverId: entry.info.id,
4391
+ launchVariant,
2639
4392
  waitMs: Date.now() - waitStart,
2640
4393
  diagnosticCount: diagnostics.length,
2641
4394
  diagnostics,
@@ -2644,25 +4397,48 @@ export class LSPService {
2644
4397
  // Document mode: 0ms grace — return as soon as any client has results.
2645
4398
  // Full mode: 400ms grace — wait a bit for other clients to catch up.
2646
4399
  const graceMs = diagnosticsMode === "document" ? 0 : EARLY_UNBLOCK_GRACE_MS;
2647
- // R8 (#714): per-promise role descriptors so raceToCompletion can apply
2648
- // a bounded aux grace once all primary-role promises have settled.
2649
- // Servers with role:"auxiliary" (opengrep, ast-grep, zizmor, …) get at
2650
- // most PI_LENS_AUX_GRACE_MS (default 500ms) after the primary settles;
2651
- // late arrivals are dropped (advisory only — they land in the client
4400
+ // R8 (#714) / #1458 S2: per-promise role descriptors so raceToCompletion
4401
+ // can apply a bounded aux grace once all primary-role promises have
4402
+ // settled. Servers with role:"auxiliary" (opengrep, ast-grep, zizmor, …)
4403
+ // get their OWN declared `aggregateWaitMs` budget after the primary
4404
+ // settles, capped by the PI_LENS_AUX_GRACE_MS global ceiling (default
4405
+ // 2000ms) — the same "declared budget, capped by a ceiling" shape
4406
+ // `touchFile`'s with-auxiliary push wait uses, so this lane can no longer
4407
+ // starve a scanner whose measured warm run (e.g. opengrep ~1.3s) is
4408
+ // shorter than the ceiling but longer than a flat short grace. Late
4409
+ // arrivals are still dropped (advisory only — they land in the client
2652
4410
  // cache and surface on the next edit). Primary-only callers have no
2653
4411
  // auxiliary descriptors, so this path is never entered and there is
2654
4412
  // zero behavior change for the single-server hot path.
2655
- const diagDescriptors = spawned.map((entry) => ({
2656
- role: entry.info.role === "auxiliary" ? "auxiliary" : "primary",
2657
- }));
4413
+ const diagDescriptors = spawned.map((entry) => {
4414
+ if (entry.info.role !== "auxiliary")
4415
+ return { role: "primary" };
4416
+ const strategy = getStrategy(entry.info.id, entry.client.getLaunchVariant?.());
4417
+ // Deleting this `budgetMs` fails no end-to-end test, and that is
4418
+ // expected rather than a coverage gap: every promise in `clientWaits`
4419
+ // already self-bounds at this same `strategy.aggregateWaitMs`, so an
4420
+ // auxiliary cuts itself off at its declared budget whether or not the
4421
+ // shared grace timer also knows about it. The descriptor is what keeps
4422
+ // the two agreeing — it is the mitigation for the over-granting
4423
+ // `raceToCompletion` documents, and it starts mattering the moment a
4424
+ // promise here stops self-bounding. The narrowing itself is pinned in
4425
+ // tests/clients/lsp/aggregation.test.ts, which can build the
4426
+ // non-self-bounded promises this path cannot.
4427
+ return { role: "auxiliary", budgetMs: strategy.aggregateWaitMs };
4428
+ });
2658
4429
  // Result-aware racing: trigger early-unblock when any client has results,
2659
4430
  // OR when a seedFirstPush server returns (its first push is authoritative
2660
4431
  // even when empty — waiting longer yields nothing more).
2661
- const perServer = await raceToCompletion(clientWaits, (results) => results.some((r) => r.diagnosticCount > 0 || getStrategy(r.serverId).seedFirstPush), {
2662
- timeoutMs: Math.max(...spawned.map((entry) => getStrategy(entry.info.id).aggregateWaitMs)),
4432
+ const perServer = await raceToCompletion(clientWaits, (results) => results.some((r) => r.diagnosticCount > 0 ||
4433
+ getStrategy(r.serverId, r.launchVariant).seedFirstPush), {
4434
+ timeoutMs: Math.max(...spawned.map((entry) => getStrategy(entry.info.id, entry.client.getLaunchVariant?.())
4435
+ .aggregateWaitMs)),
2663
4436
  graceMs,
2664
4437
  descriptors: diagDescriptors,
2665
- auxGraceMs: readEnvAuxGraceMs(),
4438
+ // #1458 S2: ceiling, not a flat wait — each auxiliary's own
4439
+ // budgetMs (above) determines the actual per-touch grace up to
4440
+ // this cap. Matches touchFile's `readEnvAuxGraceMs() ?? 2000`.
4441
+ auxGraceMs: readEnvAuxGraceMs() ?? 2000,
2666
4442
  });
2667
4443
  // Fill in any slots that timed out before producing results.
2668
4444
  const earlyUnblockedCount = spawned.length - perServer.length;
@@ -2670,7 +4446,8 @@ export class LSPService {
2670
4446
  const found = perServer.find((r) => r.serverId === entry.info.id);
2671
4447
  return (found ?? {
2672
4448
  serverId: entry.info.id,
2673
- waitMs: getStrategy(entry.info.id).aggregateWaitMs,
4449
+ launchVariant: entry.client.getLaunchVariant?.(),
4450
+ waitMs: getStrategy(entry.info.id, entry.client.getLaunchVariant?.()).aggregateWaitMs,
2674
4451
  diagnosticCount: 0,
2675
4452
  diagnostics: [],
2676
4453
  });
@@ -2741,8 +4518,15 @@ export class LSPService {
2741
4518
  this.lastKnownContentHash.delete(normalizedPath);
2742
4519
  return merged;
2743
4520
  }
4521
+ /**
4522
+ * Delegates to {@link hashDiagnosticContent} rather than re-hashing here. Every
4523
+ * comparison this hash takes part in (`publishedThisContent`, the carried-aux
4524
+ * check, the last-known content guard) is against a hash the client produced
4525
+ * with that function, so the two implementations must agree byte for byte —
4526
+ * a duplicate is a silent divergence waiting for one of them to be tuned.
4527
+ */
2744
4528
  hashContent(content) {
2745
- return createHash("sha256").update(content).digest("hex");
4529
+ return hashDiagnosticContent(content);
2746
4530
  }
2747
4531
  /**
2748
4532
  * #1095: compose the content `binding` for a merged diagnostics result across
@@ -2785,6 +4569,9 @@ export class LSPService {
2785
4569
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2786
4570
  if (!spawned)
2787
4571
  return [];
4572
+ if (!spawned.client.getOperationSupport().definition) {
4573
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for definition");
4574
+ }
2788
4575
  return spawned.client.definition(filePath, line, character);
2789
4576
  }
2790
4577
  /**
@@ -2794,6 +4581,9 @@ export class LSPService {
2794
4581
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2795
4582
  if (!spawned)
2796
4583
  return [];
4584
+ if (!spawned.client.getOperationSupport().typeDefinition) {
4585
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for typeDefinition");
4586
+ }
2797
4587
  return spawned.client.typeDefinition(filePath, line, character);
2798
4588
  }
2799
4589
  /**
@@ -2803,6 +4593,9 @@ export class LSPService {
2803
4593
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2804
4594
  if (!spawned)
2805
4595
  return [];
4596
+ if (!spawned.client.getOperationSupport().declaration) {
4597
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for declaration");
4598
+ }
2806
4599
  return spawned.client.declaration(filePath, line, character);
2807
4600
  }
2808
4601
  /**
@@ -2812,6 +4605,9 @@ export class LSPService {
2812
4605
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2813
4606
  if (!spawned)
2814
4607
  return [];
4608
+ if (!spawned.client.getOperationSupport().references) {
4609
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for references");
4610
+ }
2815
4611
  return spawned.client.references(filePath, line, character, includeDeclaration);
2816
4612
  }
2817
4613
  /**
@@ -2821,6 +4617,9 @@ export class LSPService {
2821
4617
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2822
4618
  if (!spawned)
2823
4619
  return null;
4620
+ if (!spawned.client.getOperationSupport().hover) {
4621
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for hover");
4622
+ }
2824
4623
  return spawned.client.hover(filePath, line, character);
2825
4624
  }
2826
4625
  /**
@@ -2830,6 +4629,9 @@ export class LSPService {
2830
4629
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2831
4630
  if (!spawned)
2832
4631
  return null;
4632
+ if (!spawned.client.getOperationSupport().signatureHelp) {
4633
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for signatureHelp");
4634
+ }
2833
4635
  return spawned.client.signatureHelp(filePath, line, character);
2834
4636
  }
2835
4637
  /**
@@ -2839,44 +4641,116 @@ export class LSPService {
2839
4641
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2840
4642
  if (!spawned)
2841
4643
  return [];
4644
+ if (!spawned.client.getOperationSupport().documentSymbol) {
4645
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for documentSymbol");
4646
+ }
2842
4647
  return spawned.client.documentSymbol(filePath);
2843
4648
  }
4649
+ /**
4650
+ * Resolves "the target client" for a workspace-scope query that has no
4651
+ * filePath to route through `getClientForFile`. `state.clients` is keyed
4652
+ * `${serverId}:${root}` in spawn order, not role order, so an auxiliary
4653
+ * scanner (ast-grep, opengrep, zizmor, ...) that happens to spawn first in
4654
+ * a polyglot workspace used to win every no-filePath query outright (#1812
4655
+ * — a supporting primary server spawned later never got a look-in). Scans
4656
+ * for the first LIVE client matching `predicate` (when given), preferring
4657
+ * any primary (non-`"auxiliary"` role) match over an auxiliary one — the
4658
+ * same primary-over-auxiliary preference `getClientForFile` encodes via
4659
+ * its `role !== "auxiliary"` filter (this file, `getClientForFile`) and
4660
+ * `getAliveServerIds` groups by. `isAlive()` is required for BOTH the
4661
+ * preferred and fallback candidate — mirrors `getCapabilitySnapshots`'s
4662
+ * own no-filePath branch (this file, ~line 5868), whose liveness filter
4663
+ * this helper otherwise duplicates; without it a dead primary would win
4664
+ * over a live, answering auxiliary. Role is read from the map key's
4665
+ * `serverId` prefix against `LSP_SERVERS`, the same single source of
4666
+ * truth `getCapabilitySnapshots` already parses that key from — never a
4667
+ * second, hand-rolled role table. A `serverId` prefix absent from
4668
+ * `LSP_SERVERS` (should not happen in practice — every spawned client's
4669
+ * key is built from a known server's `id`) resolves to `role === undefined`,
4670
+ * which falls through to the primary branch: unknown treated as primary,
4671
+ * never silently dropped. Returns undefined only when NO live client
4672
+ * (primary or auxiliary) matches.
4673
+ */
4674
+ selectWorkspaceScopeClient(predicate) {
4675
+ let auxFallback;
4676
+ for (const [key, client] of this.state.clients) {
4677
+ if (!client.isAlive())
4678
+ continue;
4679
+ if (predicate && !predicate(client))
4680
+ continue;
4681
+ const separator = key.indexOf(":");
4682
+ const serverId = separator >= 0 ? key.slice(0, separator) : key;
4683
+ const role = LSP_SERVERS.find((s) => s.id === serverId)?.role;
4684
+ if (role === "auxiliary") {
4685
+ if (!auxFallback)
4686
+ auxFallback = { client, serverId };
4687
+ continue;
4688
+ }
4689
+ return { client, serverId };
4690
+ }
4691
+ return auxFallback;
4692
+ }
2844
4693
  /**
2845
4694
  * Navigation: workspace-wide symbol search
4695
+ *
4696
+ * #1789: gated on the target server's advertised `workspaceSymbolProvider`
4697
+ * (the same `getOperationSupport().workspaceSymbol` single source of truth
4698
+ * `lsp-document-symbols.ts`'s documentSymbol gate reads — see clients/
4699
+ * lsp-document-symbols.ts:50).
4700
+ *
4701
+ * #1812: without a path, the no-filePath branch used to stop at
4702
+ * `state.clients`' first entry by insertion order regardless of whether it
4703
+ * supported `workspace/symbol` — an auxiliary spawned first silently ate
4704
+ * every query with `[]` and zero requests, even when a supporting primary
4705
+ * server was already spawned too. `selectWorkspaceScopeClient` now scans
4706
+ * for the first client that DOES support it, preferring a primary over an
4707
+ * auxiliary; only when none of the spawned clients support it does this
4708
+ * fall back to `[]`.
2846
4709
  */
2847
- async workspaceSymbol(query, filePath) {
4710
+ async workspaceSymbol(query, filePath, attribution) {
2848
4711
  if (filePath) {
2849
4712
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2850
4713
  if (!spawned)
2851
4714
  return [];
4715
+ if (!spawned.client.getOperationSupport().workspaceSymbol)
4716
+ return [];
2852
4717
  return spawned.client.workspaceSymbol(query);
2853
4718
  }
2854
- // Use the first active client for workspace-level queries
2855
- const clients = Array.from(this.state.clients.values());
2856
- if (clients.length === 0)
4719
+ const target = this.selectWorkspaceScopeClient((client) => client.getOperationSupport().workspaceSymbol);
4720
+ if (!target)
2857
4721
  return [];
2858
- return clients[0].workspaceSymbol(query);
4722
+ if (attribution)
4723
+ attribution.workspaceSymbol = target.serverId;
4724
+ return target.client.workspaceSymbol(query);
2859
4725
  }
2860
4726
  /**
2861
4727
  * Commands advertised for workspace/executeCommand. If filePath is given,
2862
- * the server for that file; otherwise the first active client.
4728
+ * the server for that file; otherwise the first active client, preferring
4729
+ * a primary over an auxiliary scanner spawned first (#1812 sweep — see
4730
+ * `selectWorkspaceScopeClient`).
2863
4731
  */
2864
- async getAdvertisedCommands(filePath) {
4732
+ async getAdvertisedCommands(filePath, attribution) {
2865
4733
  if (filePath) {
2866
4734
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2867
4735
  if (!spawned)
2868
4736
  return [];
2869
4737
  return spawned.client.getAdvertisedCommands();
2870
4738
  }
2871
- const first = this.state.clients.values().next().value;
2872
- return first ? first.getAdvertisedCommands() : [];
4739
+ const first = this.selectWorkspaceScopeClient();
4740
+ if (!first)
4741
+ return [];
4742
+ if (attribution)
4743
+ attribution.getAdvertisedCommands = first.serverId;
4744
+ return first.client.getAdvertisedCommands();
2873
4745
  }
2874
4746
  /**
2875
4747
  * Run a server command via workspace/executeCommand (hardened: allowlisted by
2876
4748
  * advertisement in the client). If filePath is given, target that file's
2877
- * server; otherwise the first active client.
4749
+ * server; otherwise the first active client, preferring a primary over an
4750
+ * auxiliary scanner spawned first (#1812 sweep — see
4751
+ * `selectWorkspaceScopeClient`).
2878
4752
  */
2879
- async executeCommand(filePath, command, args, mutationContext) {
4753
+ async executeCommand(filePath, command, args, mutationContext, attribution) {
2880
4754
  if (filePath) {
2881
4755
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2882
4756
  if (!spawned) {
@@ -2884,16 +4758,74 @@ export class LSPService {
2884
4758
  }
2885
4759
  return spawned.client.executeCommand(command, args, mutationContext);
2886
4760
  }
2887
- const first = this.state.clients.values().next().value;
4761
+ const first = this.selectWorkspaceScopeClient();
2888
4762
  if (!first)
2889
4763
  return { executed: false, reason: "no active LSP server" };
2890
- return first.executeCommand(command, args, mutationContext);
4764
+ if (attribution)
4765
+ attribution.executeCommand = first.serverId;
4766
+ return first.client.executeCommand(command, args, mutationContext);
4767
+ }
4768
+ /**
4769
+ * #1640: run a read-only probe command against a server that is ALREADY
4770
+ * running for this file. Two hard guarantees the mutation-channel
4771
+ * `executeCommand` above cannot give a render-path probe:
4772
+ *
4773
+ * - **Never spawns.** It resolves the already-connected client map the way
4774
+ * `isServerAliveForFile` does, instead of routing through
4775
+ * `getClientForFile` → `ensureClientForServer`. A probe that spawns a
4776
+ * language-server fleet to answer a question about how to RENDER a
4777
+ * diagnostic is a cost the caller never asked for — and under warm attach
4778
+ * the freshly spawned server's answer would not even be the warm session's
4779
+ * answer.
4780
+ * - **Never opens the mutation window.** `executeReadOnlyCommand` leaves
4781
+ * `serverEditsAllowed` and `activeMutationContext` alone, so an in-flight
4782
+ * real command's mutation context survives a concurrent probe.
4783
+ *
4784
+ * Returns `{executed:false}` — never a thrown error and never a spawn — when
4785
+ * no live client owns the file. Callers must read that as UNKNOWN.
4786
+ */
4787
+ async executeReadOnlyCommandOnLiveClient(filePath, command, args) {
4788
+ if (this.checkDestroyed()) {
4789
+ return { executed: false, reason: "lsp service destroyed" };
4790
+ }
4791
+ for (const server of getServersForFileWithConfig(filePath)) {
4792
+ const root = await this.resolveServerRoot(server, filePath);
4793
+ if (!root)
4794
+ continue;
4795
+ const entry = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
4796
+ if (!entry?.isAlive())
4797
+ continue;
4798
+ const run = entry.executeReadOnlyCommand;
4799
+ if (typeof run !== "function")
4800
+ continue;
4801
+ return run.call(entry, command, args);
4802
+ }
4803
+ return { executed: false, reason: "no live LSP server for file" };
2891
4804
  }
2892
4805
  /**
2893
4806
  * Capability snapshot for LSP operations.
2894
- * If filePath is provided, probes that server; otherwise uses first active client.
4807
+ * If filePath is provided, probes that server. Without a filePath the
4808
+ * snapshot describes the whole workspace, so each capability is ORed
4809
+ * across every client `selectWorkspaceScopeClient` would consider.
4810
+ *
4811
+ * #1846: the no-filePath branch used to report ONE client's capabilities,
4812
+ * whichever `selectWorkspaceScopeClient()` returned with no predicate. In
4813
+ * a multi-primary workspace (say `json` spawned before `typescript`), a
4814
+ * first client that does not advertise `workspaceSymbolProvider` reported
4815
+ * the operation unsupported even though a later client advertises it. The
4816
+ * tool layer then refused the call before `workspaceSymbol()` — which
4817
+ * #1812 taught to find the supporting client — was ever reached
4818
+ * (tools/lsp-navigation.ts, the `runWorkspaceSymbolOperation` gate).
4819
+ *
4820
+ * Each capability resolves through `selectWorkspaceScopeClient` with a
4821
+ * per-capability predicate, so this answer is built from the SAME liveness
4822
+ * and primary-over-auxiliary rules that route the operation itself (see
4823
+ * `selectWorkspaceScopeClient`, this file). A dead client therefore cannot
4824
+ * contribute a capability nobody can execute. Capabilities the base client
4825
+ * already reports true are skipped, so the extra scans only run for the
4826
+ * capabilities it lacks.
2895
4827
  */
2896
- async getOperationSupport(filePath) {
4828
+ async getOperationSupport(filePath, attribution) {
2897
4829
  if (filePath) {
2898
4830
  const spawned = await this.getClientForFile(filePath);
2899
4831
  if (!spawned)
@@ -2903,19 +4835,36 @@ export class LSPService {
2903
4835
  return null;
2904
4836
  return getter();
2905
4837
  }
2906
- const first = this.state.clients.values().next().value;
4838
+ const readable = (client) => typeof client.getOperationSupport === "function";
4839
+ const first = this.selectWorkspaceScopeClient(readable);
2907
4840
  if (!first)
2908
4841
  return null;
2909
- const getter = first.getOperationSupport;
2910
- if (typeof getter !== "function")
2911
- return null;
2912
- return getter();
4842
+ const aggregate = { ...first.client.getOperationSupport() };
4843
+ const contributors = {};
4844
+ for (const capability of Object.keys(aggregate)) {
4845
+ if (aggregate[capability]) {
4846
+ contributors[capability] = first.serverId;
4847
+ continue;
4848
+ }
4849
+ const supporter = this.selectWorkspaceScopeClient((client) => readable(client) && Boolean(client.getOperationSupport()[capability]));
4850
+ if (supporter) {
4851
+ aggregate[capability] = true;
4852
+ contributors[capability] = supporter.serverId;
4853
+ }
4854
+ }
4855
+ if (attribution) {
4856
+ attribution.getOperationSupport = {
4857
+ baseClientId: first.serverId,
4858
+ contributors,
4859
+ };
4860
+ }
4861
+ return aggregate;
2913
4862
  }
2914
4863
  /**
2915
4864
  * Capability snapshot for workspace diagnostics support.
2916
4865
  * If filePath is provided, probes that server; otherwise uses first active client.
2917
4866
  */
2918
- async getCapabilitySnapshots(filePath) {
4867
+ async getCapabilitySnapshots(filePath, attribution) {
2919
4868
  if (this.checkDestroyed())
2920
4869
  return [];
2921
4870
  const snapshots = [];
@@ -2955,9 +4904,17 @@ export class LSPService {
2955
4904
  launchVariant: client.getLaunchVariant?.(),
2956
4905
  });
2957
4906
  }
4907
+ if (attribution) {
4908
+ attribution.getCapabilitySnapshots = {
4909
+ clientIds: snapshots
4910
+ .slice(0, WORKSPACE_ATTRIBUTION_CLIENT_CAP)
4911
+ .map((snapshot) => snapshot.serverId),
4912
+ clientCount: snapshots.length,
4913
+ };
4914
+ }
2958
4915
  return snapshots;
2959
4916
  }
2960
- async getWorkspaceDiagnosticsSupport(filePath) {
4917
+ async getWorkspaceDiagnosticsSupport(filePath, attribution) {
2961
4918
  if (filePath) {
2962
4919
  const spawned = await this.getClientForFile(filePath);
2963
4920
  if (!spawned)
@@ -2967,13 +4924,15 @@ export class LSPService {
2967
4924
  return null;
2968
4925
  return getter();
2969
4926
  }
2970
- const first = this.state.clients.values().next().value;
4927
+ const first = this.selectWorkspaceScopeClient();
2971
4928
  if (!first)
2972
4929
  return null;
2973
- const getter = first.getWorkspaceDiagnosticsSupport;
4930
+ const getter = first.client.getWorkspaceDiagnosticsSupport;
2974
4931
  if (typeof getter !== "function")
2975
4932
  return null;
2976
- return getter();
4933
+ if (attribution)
4934
+ attribution.getWorkspaceDiagnosticsSupport = first.serverId;
4935
+ return getter.call(first.client);
2977
4936
  }
2978
4937
  /**
2979
4938
  * Navigation: available code actions at position/range
@@ -2982,6 +4941,9 @@ export class LSPService {
2982
4941
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2983
4942
  if (!spawned)
2984
4943
  return [];
4944
+ if (!spawned.client.getOperationSupport().codeAction) {
4945
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for codeAction");
4946
+ }
2985
4947
  return spawned.client.codeAction(filePath, line, character, endLine, endCharacter);
2986
4948
  }
2987
4949
  /**
@@ -2991,6 +4953,9 @@ export class LSPService {
2991
4953
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2992
4954
  if (!spawned)
2993
4955
  return null;
4956
+ if (!spawned.client.getOperationSupport().rename) {
4957
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for rename");
4958
+ }
2994
4959
  return spawned.client.rename(filePath, line, character, newName);
2995
4960
  }
2996
4961
  async renameFile(oldFilePath, newFilePath, options) {
@@ -3075,16 +5040,16 @@ export class LSPService {
3075
5040
  }));
3076
5041
  const closeFailures = [];
3077
5042
  await Promise.all(openDocuments.map(async ({ serverId, client }) => {
3078
- try {
3079
- await client.closeDocument(oldFilePath);
3080
- return undefined;
3081
- }
3082
- catch (err) {
5043
+ // #1621: bounded so one wedged server's didClose write cannot stall
5044
+ // this Promise.all — and therefore the whole rename — for every
5045
+ // other client alongside it.
5046
+ const result = await runRenameNotify(() => client.closeDocument(oldFilePath), RENAME_NOTIFY_TIMEOUT_MS);
5047
+ if (!result.ok) {
3083
5048
  closeFailures.push({
3084
5049
  serverId,
3085
- error: err instanceof Error ? err.message : String(err),
5050
+ error: result.error,
5051
+ disposition: result.disposition,
3086
5052
  });
3087
- return undefined;
3088
5053
  }
3089
5054
  }));
3090
5055
  if (closeFailures.length > 0) {
@@ -3103,8 +5068,49 @@ export class LSPService {
3103
5068
  // next genuine open (#1147 P3-7).
3104
5069
  const content = await fs.readFile(oldFilePath, "utf-8");
3105
5070
  const languageId = getLanguageId(oldFilePath) ?? "plaintext";
3106
- await Promise.all(openDocuments.map(({ client }) => client.notify.open(oldFilePath, content, languageId, true, true)));
3107
- throw new Error(`workspace/didClose failed; rename aborted: ${closeFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
5071
+ const resyncFailures = [];
5072
+ // #1621 F1: the resync write is the SAME class of notify as the didClose
5073
+ // it is repairing after — a pipe that is wedged for didClose is wedged
5074
+ // for every subsequent write on it too, so this bare await reintroduced
5075
+ // the exact unbounded primitive one Promise.all up. Bound it with the
5076
+ // same budget and record the disposition rather than let a wedged
5077
+ // resync silently move the hang here instead of removing it.
5078
+ await Promise.all(openDocuments.map(async ({ serverId, client }) => {
5079
+ const resyncResult = await runRenameNotify(() => client.notify.open(oldFilePath, content, languageId, true, true), RENAME_NOTIFY_TIMEOUT_MS);
5080
+ if (!resyncResult.ok) {
5081
+ resyncFailures.push({
5082
+ serverId,
5083
+ error: resyncResult.error,
5084
+ disposition: resyncResult.disposition,
5085
+ });
5086
+ }
5087
+ }));
5088
+ if (resyncFailures.length > 0) {
5089
+ // A resync failure is not swallowed: the affected client is left with
5090
+ // no open document at all until its next genuine open, so this is
5091
+ // logged for the same reason lsp_client_shutdown records a forced
5092
+ // teardown — a degraded resync must be countable from the log.
5093
+ logLatency({
5094
+ type: "phase",
5095
+ phase: "lsp_rename_resync_failed",
5096
+ filePath: oldFilePath,
5097
+ durationMs: 0,
5098
+ metadata: {
5099
+ failures: resyncFailures.map((failure) => ({
5100
+ serverId: failure.serverId,
5101
+ disposition: failure.disposition,
5102
+ error: failure.error,
5103
+ })),
5104
+ },
5105
+ });
5106
+ }
5107
+ const closeFailureSummary = closeFailures
5108
+ .map((failure) => `${failure.serverId} (${failure.disposition}): ${failure.error}`)
5109
+ .join("; ");
5110
+ const resyncFailureSummary = resyncFailures.length > 0
5111
+ ? ` (resync also failed: ${resyncFailures.map((failure) => `${failure.serverId} (${failure.disposition}): ${failure.error}`).join("; ")})`
5112
+ : "";
5113
+ throw new Error(`workspace/didClose failed; rename aborted: ${closeFailureSummary}${resyncFailureSummary}`);
3108
5114
  }
3109
5115
  let renameApplied;
3110
5116
  try {
@@ -3137,21 +5143,19 @@ export class LSPService {
3137
5143
  const renameDescription = `Renamed ${relOld} → ${relNew}`;
3138
5144
  await Promise.all(activeClients.map(async ({ serverId, client }) => {
3139
5145
  const opened = openDocuments.find((entry) => entry.serverId === serverId);
3140
- try {
3141
- if (opened?.oldUri) {
3142
- await client.didRenameFiles(oldFilePath, newFilePath, opened.oldUri, destinationUriPreservingSpelling(opened.oldUri, oldFilePath, newFilePath));
3143
- }
3144
- else {
3145
- await client.didRenameFiles(oldFilePath, newFilePath);
3146
- }
3147
- return undefined;
3148
- }
3149
- catch (err) {
5146
+ // #1621: bounded for the same reason as the didClose notify above —
5147
+ // rename propagation is best-effort advice to servers, not a
5148
+ // correctness gate, so a wedged client's notify must not stall the
5149
+ // healthy clients settling alongside it in this Promise.all.
5150
+ const result = await runRenameNotify(() => opened?.oldUri
5151
+ ? client.didRenameFiles(oldFilePath, newFilePath, opened.oldUri, destinationUriPreservingSpelling(opened.oldUri, oldFilePath, newFilePath))
5152
+ : client.didRenameFiles(oldFilePath, newFilePath), RENAME_NOTIFY_TIMEOUT_MS);
5153
+ if (!result.ok) {
3150
5154
  didRenameFailures.push({
3151
5155
  serverId,
3152
- error: err instanceof Error ? err.message : String(err),
5156
+ error: result.error,
5157
+ disposition: result.disposition,
3153
5158
  });
3154
- return undefined;
3155
5159
  }
3156
5160
  }));
3157
5161
  const files = [...new Set([...applied.files, oldFilePath, newFilePath])];
@@ -3201,6 +5205,9 @@ export class LSPService {
3201
5205
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
3202
5206
  if (!spawned)
3203
5207
  return [];
5208
+ if (!spawned.client.getOperationSupport().implementation) {
5209
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for implementation");
5210
+ }
3204
5211
  return spawned.client.implementation(filePath, line, character);
3205
5212
  }
3206
5213
  /**
@@ -3210,24 +5217,38 @@ export class LSPService {
3210
5217
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
3211
5218
  if (!spawned)
3212
5219
  return [];
5220
+ if (!spawned.client.getOperationSupport().callHierarchy) {
5221
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for prepareCallHierarchy");
5222
+ }
3213
5223
  return spawned.client.prepareCallHierarchy(filePath, line, character);
3214
5224
  }
3215
5225
  /**
3216
5226
  * Navigation: find incoming calls (callers)
5227
+ *
5228
+ * #1803: gated on the target server's advertised `callHierarchyProvider`
5229
+ * (the same `getOperationSupport().callHierarchy` single source of truth
5230
+ * populated by `detectOperationSupport` in clients/lsp/client.ts — see
5231
+ * client.ts:5253). Mirrors the #1789 gate on `workspaceSymbol` above.
3217
5232
  */
3218
5233
  async incomingCalls(item) {
3219
5234
  const spawned = await this.getClientForFile(uriToPath(item.uri), NAV_CLIENT_WAIT_TIMEOUT_MS);
3220
5235
  if (!spawned)
3221
5236
  return [];
5237
+ if (!spawned.client.getOperationSupport().callHierarchy)
5238
+ return [];
3222
5239
  return spawned.client.incomingCalls(item);
3223
5240
  }
3224
5241
  /**
3225
5242
  * Navigation: find outgoing calls (callees)
5243
+ *
5244
+ * #1803: same gate as `incomingCalls` above.
3226
5245
  */
3227
5246
  async outgoingCalls(item) {
3228
5247
  const spawned = await this.getClientForFile(uriToPath(item.uri), NAV_CLIENT_WAIT_TIMEOUT_MS);
3229
5248
  if (!spawned)
3230
5249
  return [];
5250
+ if (!spawned.client.getOperationSupport().callHierarchy)
5251
+ return [];
3231
5252
  return spawned.client.outgoingCalls(item);
3232
5253
  }
3233
5254
  /**
@@ -3291,10 +5312,13 @@ export class LSPService {
3291
5312
  * attempts never proved it can answer diagnostics. Warm-up stays
3292
5313
  * `clientScope:"primary"` (not the sweep's `"all"`) on purpose: `"all"`
3293
5314
  * would additionally spawn the sweep-EXCLUDED auxiliaries
3294
- * (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`), and because `touchFile`'s
3295
- * `inconclusive` flag is touch-wide, one slow advisory auxiliary would then
3296
- * suppress the `demonstratedReady` marking for a perfectly healthy primary —
3297
- * falsely condemning it. Recording per-primary-server outcomes and skipping
5315
+ * (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`), which is work this warm-up does not
5316
+ * need. (Before #1549 it was also unsafe: `touchFile`'s `inconclusive` flag was
5317
+ * touch-wide, so one slow advisory auxiliary suppressed the `demonstratedReady`
5318
+ * marking for a perfectly healthy primary — falsely condemning it. The verdict is
5319
+ * per-server now, and the marking loop skips only the servers that did not answer
5320
+ * for themselves, so the scope choice is a cost argument rather than a
5321
+ * correctness one.) Recording per-primary-server outcomes and skipping
3298
5322
  * on those is the correct, non-regressing way to cover the servers the sweep
3299
5323
  * actually gates on (the sweep groups by primary server, so the group this
3300
5324
  * warms IS the one whose per-file touches would drag).
@@ -3487,9 +5511,30 @@ export class LSPService {
3487
5511
  * This is intentionally expensive and used only by explicit project-wide tools.
3488
5512
  */
3489
5513
  async runWorkspaceDiagnostics(cwd, options = {}) {
5514
+ // #1618: hold the shared sweep gate for this call's ENTIRE lifetime —
5515
+ // counter-based (not a boolean) so two overlapping `mode=full` calls each
5516
+ // release independently, and try/finally so a throw or an aborted sweep
5517
+ // still releases it (a leaked hold would permanently disable idle reset,
5518
+ // the inverse defect). While held, `clients/runtime-turn.ts`'s idle-reset
5519
+ // timer defers instead of destroying this service mid-sweep.
5520
+ const releaseSweepHold = acquireWorkspaceSweepHold();
5521
+ try {
5522
+ return await this.runWorkspaceDiagnosticsSwept(cwd, options);
5523
+ }
5524
+ finally {
5525
+ releaseSweepHold();
5526
+ }
5527
+ }
5528
+ async runWorkspaceDiagnosticsSwept(cwd, options = {}) {
3490
5529
  const startedAt = Date.now();
3491
5530
  const root = path.resolve(cwd);
3492
5531
  const { signal } = options;
5532
+ // #1783: second heartbeat for the drift backstop. The per-file touch
5533
+ // below already carries one, but a round that answers every file from
5534
+ // the #671 cache never calls touchFile at all — and that is precisely the
5535
+ // round whose stale answers the pi-codec witness recorded. Same rate
5536
+ // limit, same non-awaited contract.
5537
+ void this.sweepDocumentDrift().catch(() => { });
3493
5538
  // Cap the per-file LSP sweep: a Next.js-scale project can route thousands
3494
5539
  // of files through the language server at concurrency 8, and without a
3495
5540
  // caller cap that grinds for tens of minutes (#341). `maxFiles` lets
@@ -3565,6 +5610,16 @@ export class LSPService {
3565
5610
  if (cachedResults.length > 0) {
3566
5611
  options.onProgress?.(completed, files.length);
3567
5612
  }
5613
+ // #1782: which files this sweep is REPLAYING from cache, and which of those
5614
+ // a workspace pull then explicitly re-answered clean. A project-wide pull
5615
+ // report names files far beyond the group that asked for it; an explicit
5616
+ // zero-diagnostic answer for a replayed file is authoritative and must
5617
+ // supersede the replay, in the sweep's result list AND on disk. Without
5618
+ // this, a server that re-checked a file and found it clean could not
5619
+ // dislodge the stale entry by any means available to a user — the
5620
+ // 2026-08-20 dogfood's 23:07 record.
5621
+ const cacheServedKeys = new Set(cachedResults.map((result) => normalizeMapKey(result.filePath)));
5622
+ const supersededCacheKeys = new Set();
3568
5623
  // Per-file scan mtime captured as each file completes below, so a
3569
5624
  // confirmed fresh result can be written back into the cache with the
3570
5625
  // mtime it was ACTUALLY scanned at (not re-stat'd after the fact, which
@@ -3681,8 +5736,27 @@ export class LSPService {
3681
5736
  const preOpenAttempt = withDeadline((async () => {
3682
5737
  const { clients } = await this.getClientsForFile(filePath, WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS);
3683
5738
  for (const entry of clients) {
5739
+ // #1714: this pass is the sweep's SECOND source of `didOpen`
5740
+ // volume, and it reaches the server without passing the drain
5741
+ // barrier. Charge it to the same backlog ledger, and leave a
5742
+ // scanner that is already at its ceiling out of the burst.
5743
+ let auxKey;
5744
+ if (entry.info.role === "auxiliary") {
5745
+ auxKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
5746
+ if (auxKey && this.auxNotifyBacklogAtCeiling(auxKey, entry)) {
5747
+ continue;
5748
+ }
5749
+ }
3684
5750
  try {
3685
5751
  await entry.client.notify.open(filePath, content, languageId);
5752
+ if (auxKey)
5753
+ this.noteAuxNotifyIssued(auxKey, entry.client);
5754
+ // #1783: deliberately NOT recorded for the drift backstop.
5755
+ // This pass can skip a scanner at its backlog ceiling, so its
5756
+ // coverage is partial by design, and `processFile` runs
5757
+ // `touchFile` over every file in the group immediately after
5758
+ // — that call records with a full-coverage check. Stamping
5759
+ // here would only claim a coverage this loop does not have.
3686
5760
  }
3687
5761
  catch {
3688
5762
  // Best-effort: a failed pre-open just means processFile's own
@@ -3706,6 +5780,26 @@ export class LSPService {
3706
5780
  : preOpenAttempt);
3707
5781
  }
3708
5782
  };
5783
+ // #1618: a service destroyed mid-sweep (idle-reset race, an explicit
5784
+ // `resetLSPService` call, session replacement, …) must stop the loop for
5785
+ // every file it has not yet reached and record WHY — never a bare
5786
+ // `timedOut` that reads identically to a budget timeout. Cheap: no
5787
+ // language-server round trip, just a results push.
5788
+ const markServiceDestroyed = (remainingFiles) => {
5789
+ for (const filePath of remainingFiles) {
5790
+ results.push({
5791
+ filePath,
5792
+ diagnostics: [],
5793
+ count: 0,
5794
+ timedOut: true,
5795
+ unconfirmedReason: "service_destroyed",
5796
+ writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
5797
+ });
5798
+ timedOutFiles += 1;
5799
+ completed += 1;
5800
+ }
5801
+ options.onProgress?.(completed, files.length);
5802
+ };
3709
5803
  const processFile = async (filePath) => {
3710
5804
  try {
3711
5805
  const content = contentCache.get(filePath) ??
@@ -3745,7 +5839,17 @@ export class LSPService {
3745
5839
  // answer, so `available` implies confirmed) — wrap it as `{ diags }`;
3746
5840
  // the incumbent branch already returns the wrapper.
3747
5841
  const touchResult = attached?.available
3748
- ? { diags: attached.response.diagnostics }
5842
+ ? {
5843
+ diags: attached.response.diagnostics,
5844
+ // #1470: the incumbent's coverage gap crosses the socket as an
5845
+ // explicit DTO field, so carry it onto the wrapper the sweep
5846
+ // reads. Dropping it here would let a partially covered
5847
+ // incumbent answer be persisted as a confirmed sweep result —
5848
+ // the same false clean this change closes on the local route.
5849
+ ...(attached.response.unconfirmedServerIds !== undefined && {
5850
+ unconfirmedServerIds: attached.response.unconfirmedServerIds,
5851
+ }),
5852
+ }
3749
5853
  : await withDeadline(this.touchFile(filePath, content, {
3750
5854
  diagnostics: "document",
3751
5855
  collectDiagnostics: true,
@@ -3767,9 +5871,37 @@ export class LSPService {
3767
5871
  // `perFileMs` deadline, which only catches a touch that never returned at
3768
5872
  // all within budget. Either one means the result wasn't confirmed.
3769
5873
  const inconclusive = touchResult?.inconclusive === true;
5874
+ // #1470/#1493: an auxiliary that never reported — cut off by the grace
5875
+ // timer, or silent with nothing published for this content — is the
5876
+ // THIRD reason this result is not a confirmed observation, and it is
5877
+ // deliberately not `inconclusive`. The
5878
+ // record loop below persists every `!timedOut` result into the workspace
5879
+ // cache, so reading `inconclusive` alone caches a partially covered
5880
+ // answer as clean and replays it on every later sweep. The warm-attach
5881
+ // incumbent reaches this branch through the aux GRACE wait (its touch
5882
+ // runs `clientScope: "with-auxiliary"`), where all three no-answer shapes
5883
+ // arise. #1459 added the two pre-wait doors for the sweep's own local
5884
+ // `clientScope: "all"` touch — a scanner whose breaker was open, or whose
5885
+ // resync the fan-out gate deferred. #1533 closed the last hole: `"all"`
5886
+ // still never enters the grace wait, but it now derives the SAME evidence
5887
+ // from post-wait state, so a silent auxiliary narrows this scope too
5888
+ // instead of aggregating as a confirmed clean. Every route is gated here.
5889
+ const unconfirmedServerIds = touchCoverageGap(touchResult);
5890
+ const coverageGap = unconfirmedServerIds.length > 0;
5891
+ // #1549: the sweep verdict is per answering lane. An auxiliary gap
5892
+ // narrows coverage, but a primary answer remains usable; only absence of
5893
+ // the touch result or a primary-scoped inconclusive verdict poisons it.
3770
5894
  const timedOut = touchResult === undefined || inconclusive;
3771
5895
  if (timedOut)
3772
5896
  timedOutFiles += 1;
5897
+ // #1618: WHY, in priority order — the outer deadline (nothing came
5898
+ // back at all) outranks an inner inconclusive signal, which outranks
5899
+ // a narrower auxiliary coverage gap.
5900
+ const unconfirmedReason = touchResult === undefined
5901
+ ? "budget"
5902
+ : inconclusive
5903
+ ? "inconclusive"
5904
+ : undefined;
3773
5905
  // #1104 (shape 5 — AGENTS.md): the touch's content binding is an
3774
5906
  // EXPLICIT enumerable field on the wrapper now (#1179), so it survives
3775
5907
  // `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
@@ -3799,7 +5931,10 @@ export class LSPService {
3799
5931
  filePath,
3800
5932
  diagnostics: filteredDiagnostics ?? [],
3801
5933
  count: filteredDiagnostics?.length ?? 0,
3802
- timedOut,
5934
+ ...(timedOut && { timedOut: true, unconfirmedReason }),
5935
+ ...(coverageGap && {
5936
+ unconfirmedServerIds: [...unconfirmedServerIds],
5937
+ }),
3803
5938
  contentHash: rawBinding?.contentHash,
3804
5939
  boundToCurrentDisk: rawBinding?.boundToCurrentDisk,
3805
5940
  writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
@@ -3813,8 +5948,12 @@ export class LSPService {
3813
5948
  error: err instanceof Error ? err.message : String(err),
3814
5949
  // An errored check is exactly as inconclusive as a timed-out one —
3815
5950
  // no confirmed result was obtained, so reconciliation (#571) must
3816
- // skip it the same way.
5951
+ // skip it the same way. #1618: distinct from every OTHER
5952
+ // `timedOut: true` reason — an `error` must never render as "didn't
5953
+ // complete within budget" (the old dead-subtraction bug in
5954
+ // tools/lens-diagnostics.ts's `unconfirmedErrored` computation).
3817
5955
  timedOut: true,
5956
+ unconfirmedReason: "error",
3818
5957
  writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
3819
5958
  });
3820
5959
  }
@@ -3848,13 +5987,29 @@ export class LSPService {
3848
5987
  await runPerServerGroups(groups, groupWorkers, async (group) => {
3849
5988
  if (signal?.aborted)
3850
5989
  return;
5990
+ // #1618: checked before any per-group work (pull attempt, warm-up,
5991
+ // per-file loop) so a service already destroyed when this group
5992
+ // starts never pays for a language-server round trip it cannot get.
5993
+ if (this.checkDestroyed()) {
5994
+ markServiceDestroyed(group.files);
5995
+ return;
5996
+ }
3851
5997
  // Fast path: one project-wide pull for the whole group (opt-in).
3852
5998
  if (!isWarmAttached() &&
3853
5999
  workspacePullEnabled &&
3854
6000
  !group.multiServer) {
3855
- const pulled = await this.tryWorkspacePull(group.files, perFileMs);
6001
+ const pulled = await this.tryWorkspacePull(group.files, perFileMs, cacheServedKeys);
3856
6002
  if (pulled) {
3857
- for (const result of pulled) {
6003
+ // #1782: an explicit zero-diagnostic answer for a file this
6004
+ // sweep served from cache SUPERSEDES that cached replay. Route
6005
+ // it through the same result list every other answer uses, so
6006
+ // the cache write below overwrites the stale entry and the
6007
+ // footer reconcile in `tools/lens-diagnostics.ts` clears the
6008
+ // widget rows — no second eviction path to keep in step.
6009
+ for (const clean of pulled.extraClean) {
6010
+ supersededCacheKeys.add(normalizeMapKey(clean.filePath));
6011
+ }
6012
+ for (const result of [...pulled.results, ...pulled.extraClean]) {
3858
6013
  results.push({
3859
6014
  ...result,
3860
6015
  writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
@@ -3919,6 +6074,7 @@ export class LSPService {
3919
6074
  diagnostics: [],
3920
6075
  count: 0,
3921
6076
  timedOut: true,
6077
+ unconfirmedReason: "inconclusive",
3922
6078
  skippedWarmupFailure: true,
3923
6079
  });
3924
6080
  timedOutFiles += 1;
@@ -3940,6 +6096,15 @@ export class LSPService {
3940
6096
  for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
3941
6097
  if (signal?.aborted)
3942
6098
  return;
6099
+ // #1618: a service destroyed WHILE this group's chunk loop was
6100
+ // already running (the group-start check above can't see a
6101
+ // destruction that lands mid-loop) — stop here and mark every
6102
+ // file from this chunk onward, rather than letting the remaining
6103
+ // chunks pay for pre-opens/touches against a torn-down service.
6104
+ if (this.checkDestroyed()) {
6105
+ markServiceDestroyed(group.files.slice(chunkStart));
6106
+ return;
6107
+ }
3943
6108
  const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
3944
6109
  await preOpenGroupFiles(chunk);
3945
6110
  for (const filePath of chunk) {
@@ -3947,10 +6112,33 @@ export class LSPService {
3947
6112
  // results are returned as a partial.
3948
6113
  if (signal?.aborted)
3949
6114
  return;
6115
+ if (this.checkDestroyed()) {
6116
+ markServiceDestroyed(group.files.slice(group.files.indexOf(filePath)));
6117
+ return;
6118
+ }
3950
6119
  await processFile(filePath);
3951
6120
  }
3952
6121
  }
3953
6122
  }, signal);
6123
+ // #1618: per-reason tally alongside the flat `timedOutFiles` count (kept
6124
+ // for the existing `scripts/analyze-pi-lens-logs.mjs` consumer) — a
6125
+ // dashboard reading only the flat count can no longer mistake 81
6126
+ // service-destroyed files for 81 budget-exhausted ones.
6127
+ const unconfirmedByReason = {};
6128
+ for (const result of results) {
6129
+ if (!result.timedOut)
6130
+ continue;
6131
+ const reason = result.unconfirmedReason ?? "budget";
6132
+ unconfirmedByReason[reason] = (unconfirmedByReason[reason] ?? 0) + 1;
6133
+ }
6134
+ const partiallyCoveredFiles = results.filter((result) => (result.unconfirmedServerIds?.length ?? 0) > 0).length;
6135
+ // Code-unit comparator (#1883): this list ships as the
6136
+ // `unconfirmedServerIds` field on the `lsp_workspace_diagnostics` record,
6137
+ // so its order must be deterministic across locales — localeCompare is
6138
+ // deliberately avoided.
6139
+ const unconfirmedServerIds = [
6140
+ ...new Set(results.flatMap((result) => result.unconfirmedServerIds ?? [])),
6141
+ ].sort((a, b) => Number(a > b) - Number(a < b));
3954
6142
  logLatency({
3955
6143
  type: "phase",
3956
6144
  phase: "lsp_workspace_diagnostics",
@@ -3964,6 +6152,9 @@ export class LSPService {
3964
6152
  concurrency: groupWorkers,
3965
6153
  maxFiles,
3966
6154
  timedOutFiles,
6155
+ unconfirmedByReason,
6156
+ partiallyCoveredFiles,
6157
+ ...(unconfirmedServerIds.length > 0 && { unconfirmedServerIds }),
3967
6158
  aborted: signal?.aborted ?? false,
3968
6159
  },
3969
6160
  });
@@ -3980,8 +6171,12 @@ export class LSPService {
3980
6171
  // freshness check; nothing here needs to explicitly evict them).
3981
6172
  for (const result of results) {
3982
6173
  const scannedAt = scannedMtimeByFile.get(result.filePath);
3983
- if (result.error || result.timedOut || scannedAt === undefined)
6174
+ if (result.error ||
6175
+ result.timedOut ||
6176
+ (result.unconfirmedServerIds?.length ?? 0) > 0 ||
6177
+ scannedAt === undefined) {
3984
6178
  continue;
6179
+ }
3985
6180
  // #1104: thread the per-result `contentHash` (from either the
3986
6181
  // `tryWorkspacePull` fast path or a per-file touch's own #1095 binding)
3987
6182
  // into the cache entry — previously this call never passed one, so
@@ -3993,15 +6188,37 @@ export class LSPService {
3993
6188
  workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
3994
6189
  }
3995
6190
  workspaceDiagnosticsCacheCtx.persist();
3996
- return [...cachedResults, ...results].filter(Boolean);
6191
+ // #1782: drop every cached replay a pull answered clean — returning both
6192
+ // would hand the footer reconcile two contradictory results for one file,
6193
+ // and the stale one could win on ordering.
6194
+ const servedCacheResults = supersededCacheKeys.size === 0
6195
+ ? cachedResults
6196
+ : cachedResults.filter((result) => !supersededCacheKeys.has(normalizeMapKey(result.filePath)));
6197
+ return [...servedCacheResults, ...results].filter(Boolean);
3997
6198
  }
3998
6199
  /**
3999
6200
  * #387 Item 2: one `workspace/diagnostic` pull covering a whole server group,
4000
6201
  * instead of N per-file opens. Returns per-file results (files absent from the
4001
6202
  * report are reported clean), or `undefined` when the server doesn't advertise
4002
6203
  * workspace pull / the pull fails — the caller then falls back to per-file.
6204
+ *
6205
+ * #1782: the report is project-wide, so it routinely names files this group
6206
+ * never asked about — including files this sweep already served from the
6207
+ * cache. Those answers used to be dropped on the floor: the mapping below
6208
+ * only ever looked up `groupFiles`. That is how a server can explicitly
6209
+ * re-answer a file with ZERO diagnostics while the cache and the widget keep
6210
+ * rendering its stale blockers, which is what the 2026-08-20 dogfood recorded
6211
+ * at 23:07. `reanswerFor` opts a caller into those extra answers: pass the
6212
+ * normalized keys of files served from cache, and any of them the report
6213
+ * explicitly names with zero diagnostics comes back in `extraClean` as a
6214
+ * confirmed clean result.
6215
+ *
6216
+ * Only an EXPLICIT zero-diagnostic entry qualifies. Absence from the report
6217
+ * reads as clean for `groupFiles`, which this sweep did ask about, but it is
6218
+ * genuinely UNKNOWN for a file nobody asked about — a server may report only
6219
+ * what it re-checked.
4003
6220
  */
4004
- async tryWorkspacePull(groupFiles, perFileMs) {
6221
+ async tryWorkspacePull(groupFiles, perFileMs, reanswerFor) {
4005
6222
  try {
4006
6223
  const first = groupFiles[0];
4007
6224
  if (!first)
@@ -4015,11 +6232,13 @@ export class LSPService {
4015
6232
  const report = await spawned.client.requestWorkspaceDiagnostics(Math.max(perFileMs, workspacePullBudgetMs()));
4016
6233
  if (!report)
4017
6234
  return undefined;
6235
+ // Last-wins per file: the report builder does not dedup, so a server
6236
+ // naming the same URI twice appears twice in `report` (#1786 review F2).
4018
6237
  const byPath = new Map();
4019
6238
  for (const entry of report) {
4020
6239
  byPath.set(normalizeMapKey(entry.filePath), entry);
4021
6240
  }
4022
- return groupFiles.map((filePath) => {
6241
+ const results = groupFiles.map((filePath) => {
4023
6242
  const entry = byPath.get(normalizeMapKey(filePath));
4024
6243
  const diagnostics = entry?.diagnostics ?? [];
4025
6244
  // A pull that got here returned a real workspace/diagnostic report
@@ -4036,6 +6255,48 @@ export class LSPService {
4036
6255
  contentHash: entry?.contentHash,
4037
6256
  };
4038
6257
  });
6258
+ // #1782: harvest explicit clean answers for files this group never asked
6259
+ // about but the caller is serving from cache. Same confirmed status as
6260
+ // the mapping above — it is the same report.
6261
+ //
6262
+ // #1786 review F2: iterate `byPath`, not `report`. The report BUILDER
6263
+ // (`clients/lsp/client.ts`'s `requestWorkspaceDiagnostics`) pushes one
6264
+ // output entry per report item with no dedup, so a server that names the
6265
+ // same URI twice yields two entries for one file. Walking the raw list
6266
+ // would emit two results for one file — breaking the caller's
6267
+ // one-result-per-file invariant — and would let a zero-diagnostic
6268
+ // duplicate evict a cached blocker that the SAME report also reports as
6269
+ // still failing. `byPath` is last-wins and unique per file, and
6270
+ // `withFindings` refuses eviction whenever ANY entry for that file
6271
+ // reports findings, whatever the order. Refusing costs a stale entry one
6272
+ // more sweep; evicting on a contradicted answer discards a live blocker.
6273
+ const extraClean = [];
6274
+ if (reanswerFor && reanswerFor.size > 0) {
6275
+ const asked = new Set(groupFiles.map((f) => normalizeMapKey(f)));
6276
+ const withFindings = new Set();
6277
+ for (const entry of report) {
6278
+ if (entry.diagnostics.length > 0) {
6279
+ withFindings.add(normalizeMapKey(entry.filePath));
6280
+ }
6281
+ }
6282
+ for (const [key, entry] of byPath) {
6283
+ // The membership filter is load-bearing: a report names files far
6284
+ // beyond this sweep, and a file nobody asked about and nothing is
6285
+ // replaying has no business entering the sweep's results.
6286
+ if (asked.has(key) || !reanswerFor.has(key))
6287
+ continue;
6288
+ if (entry.diagnostics.length > 0 || withFindings.has(key))
6289
+ continue;
6290
+ extraClean.push({
6291
+ filePath: entry.filePath,
6292
+ diagnostics: [],
6293
+ count: 0,
6294
+ timedOut: false,
6295
+ contentHash: entry.contentHash,
6296
+ });
6297
+ }
6298
+ }
6299
+ return { results, extraClean };
4039
6300
  }
4040
6301
  catch {
4041
6302
  return undefined;
@@ -4119,6 +6380,35 @@ export class LSPService {
4119
6380
  supportsLSP(filePath) {
4120
6381
  return getServersForFileWithConfig(filePath).length > 0;
4121
6382
  }
6383
+ /**
6384
+ * Check whether the PRIMARY server for this file is currently mid-spawn
6385
+ * (`state.inFlight`, keyed `${server.id}:${normalizeMapKey(root)}` — see
6386
+ * :2413). Lets a caller whose own wait budget expires distinguish "the
6387
+ * server hasn't finished its first spawn yet" from "the server is running
6388
+ * but slow/wedged" (#1766) — a cold spawn still in flight is not a verdict
6389
+ * on a server that doesn't exist yet.
6390
+ *
6391
+ * Mirrors the `role !== "auxiliary"` filter getClientForFile applies at
6392
+ * :2146-2148 (kept coupled to that line intentionally): auxiliary servers
6393
+ * (opengrep, typos, …) spawn routinely and concurrently with an ALREADY
6394
+ * ALIVE primary (dispatch/runners/lsp.ts's with-auxiliary path fires one
6395
+ * per edit for most files via TYPOS_EXTENSIONS). Without this filter, an
6396
+ * unrelated auxiliary spawn would downgrade a genuinely wedged primary to
6397
+ * a benign "spawn-in-flight" verdict — the worse misreport direction.
6398
+ *
6399
+ * The touch path supplies roots resolved before acquisition. Matching the full
6400
+ * server/root key prevents another workspace's spawn from relabeling this one.
6401
+ * Root resolution is already complete, and this check stays synchronous.
6402
+ * The failure mode is limited to the matching workspace key.
6403
+ * Pure lookup — does not spawn or wait for a client.
6404
+ */
6405
+ isSpawnInFlight(_filePath, resolvedRoots = new Map()) {
6406
+ for (const [serverId, root] of resolvedRoots) {
6407
+ if (this.state.inFlight.has(`${serverId}:${root}`))
6408
+ return true;
6409
+ }
6410
+ return false;
6411
+ }
4122
6412
  /**
4123
6413
  * Check whether an LSP client is already alive for a file.
4124
6414
  * Lightweight — does not spawn or wait for a client.
@@ -4189,6 +6479,21 @@ export class LSPService {
4189
6479
  });
4190
6480
  this.state.clients.clear();
4191
6481
  this.state.broken.clear();
6482
+ // #1459: every gated client is gone, so no outstanding-write record can
6483
+ // describe a live one. The gate's identity check already neutralises a stale
6484
+ // entry; clearing keeps the map honest rather than relying on that.
6485
+ this.outstandingAuxNotifyWrites.clear();
6486
+ // #1714: same reasoning — a backlog count belongs to a client generation,
6487
+ // and every client is gone. `session_start` reaches this through the service
6488
+ // reset, so the pacing state re-arms with the session rather than living for
6489
+ // the process.
6490
+ this.auxNotifyInflight.clear();
6491
+ // #1783: the drift records describe what THESE clients hold. Every client
6492
+ // is gone, so every record is a claim about a dead server's view. Keeping
6493
+ // them would make the first sweep of the next generation resync files no
6494
+ // one has open yet, and — like the pacing state above — this is state that
6495
+ // must re-arm at `session_start`, which reaches it through this reset.
6496
+ this.documentDrift.clear();
4192
6497
  this.workspaceProbeLogged.clear();
4193
6498
  this.warmStartLogged.clear();
4194
6499
  }
@@ -4344,7 +6649,27 @@ export function getLSPService() {
4344
6649
  export async function isAuxiliaryLspAlive(serverId, filePath) {
4345
6650
  return getLSPService().isServerAliveForFile(serverId, filePath);
4346
6651
  }
6652
+ /**
6653
+ * Cross-layer seam (#1668) for callers outside `lsp/` (bash/write tool-result
6654
+ * handling) that observe a disk change no open-document sync path will ever
6655
+ * report — an external delete/create/modify. Delivers to already-active
6656
+ * clients only; see `LSPService.notifyExternalFileChange`.
6657
+ */
6658
+ export async function notifyExternalFileChange(filePath, type) {
6659
+ return getLSPService().notifyExternalFileChange(filePath, type);
6660
+ }
4347
6661
  export function resetLSPService(options = {}) {
6662
+ // A new session must get its own classic-tsserver-repair attempt: the
6663
+ // guard is a process-lifetime flag (see resetClassicTsRepairGuard), so a
6664
+ // repair that failed transiently in an earlier session must not stay
6665
+ // latched for the rest of the extension-host process (#1570).
6666
+ if (options.reason === "session_start") {
6667
+ resetClassicTsRepairGuard();
6668
+ // #1618 (R3): a hold from a PRIOR generation's sweep must never survive
6669
+ // a session boundary — state that must re-arm at session_start cannot
6670
+ // hide behind a leaked/stuck guard from the generation before it.
6671
+ clearWorkspaceSweepHoldForSessionStart();
6672
+ }
4348
6673
  const retiringService = globalLSPService;
4349
6674
  globalLSPService = null;
4350
6675
  if (!retiringService)