@herbertgao/pi-extensions 2026.8.4 → 2026.8.6

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 (360) hide show
  1. package/README.md +5 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +11 -3
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +11 -3
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +53 -14
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
  14. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  15. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +18 -0
  16. package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
  17. package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
  18. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +20 -4
  21. package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
  22. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
  23. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +15 -6
  25. package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
  26. package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
  27. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +9 -5
  28. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
  29. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
  30. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -0
  31. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
  32. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  33. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
  34. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
  35. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
  36. package/node_modules/@narumitw/pi-btw/README.md +32 -24
  37. package/node_modules/@narumitw/pi-btw/package.json +6 -6
  38. package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
  39. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -13
  40. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  41. package/node_modules/pi-lens/CHANGELOG.md +2535 -954
  42. package/node_modules/pi-lens/README.md +13 -0
  43. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
  44. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +314 -0
  45. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
  46. package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
  47. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
  48. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
  49. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
  50. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
  51. package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
  52. package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
  53. package/node_modules/pi-lens/dist/clients/biome-client.js +136 -24
  54. package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
  55. package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
  56. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
  57. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  58. package/node_modules/pi-lens/dist/clients/bus-publish.js +19 -4
  59. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
  60. package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
  61. package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
  62. package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
  63. package/node_modules/pi-lens/dist/clients/cascade-format.js +57 -2
  64. package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
  65. package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
  66. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
  67. package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
  68. package/node_modules/pi-lens/dist/clients/console-guard-install.js +26 -0
  69. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
  70. package/node_modules/pi-lens/dist/clients/dead-code-client.js +137 -31
  71. package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
  72. package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
  73. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
  74. package/node_modules/pi-lens/dist/clients/dependency-checker.js +361 -77
  75. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +65 -13
  76. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +18 -4
  77. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +176 -29
  78. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
  79. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
  80. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
  81. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
  82. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
  83. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1172 -683
  84. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
  85. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
  86. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
  87. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
  88. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +47 -6
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +473 -42
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  114. package/node_modules/pi-lens/dist/clients/disposition-publish.js +18 -4
  115. package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
  116. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  117. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
  118. package/node_modules/pi-lens/dist/clients/extension-log.js +432 -0
  119. package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
  120. package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
  121. package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
  122. package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
  123. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  124. package/node_modules/pi-lens/dist/clients/format-events-publish.js +51 -7
  125. package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
  126. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
  127. package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
  128. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
  129. package/node_modules/pi-lens/dist/clients/git-guard.js +678 -11
  130. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
  131. package/node_modules/pi-lens/dist/clients/go-client.js +23 -40
  132. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +124 -7
  133. package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
  134. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
  135. package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
  136. package/node_modules/pi-lens/dist/clients/installer/index.js +395 -100
  137. package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
  138. package/node_modules/pi-lens/dist/clients/jscpd-client.js +27 -55
  139. package/node_modules/pi-lens/dist/clients/knip-client.js +58 -42
  140. package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
  141. package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
  142. package/node_modules/pi-lens/dist/clients/latency-logger.js +72 -1
  143. package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
  144. package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
  145. package/node_modules/pi-lens/dist/clients/lens-events.js +63 -9
  146. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
  147. package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
  148. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +73 -0
  149. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
  150. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  151. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +59 -14
  152. package/node_modules/pi-lens/dist/clients/lsp/client.js +662 -56
  153. package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
  154. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +115 -0
  155. package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
  156. package/node_modules/pi-lens/dist/clients/lsp/index.js +2025 -800
  157. package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
  158. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  159. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  160. package/node_modules/pi-lens/dist/clients/lsp/server.js +437 -86
  161. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
  162. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  163. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
  164. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  165. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
  166. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
  167. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +127 -32
  168. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
  169. package/node_modules/pi-lens/dist/clients/mcp/session.js +235 -14
  170. package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
  171. package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
  172. package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
  173. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  174. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
  175. package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
  176. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
  177. package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
  178. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
  179. package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
  180. package/node_modules/pi-lens/dist/clients/pipeline.js +76 -15
  181. package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
  182. package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
  183. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
  184. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
  185. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
  186. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  187. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
  188. package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
  189. package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
  190. package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
  191. package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
  192. package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
  193. package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
  194. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
  195. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
  196. package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
  197. package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
  198. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
  199. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
  200. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
  201. package/node_modules/pi-lens/dist/clients/review-graph/query.js +24 -0
  202. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
  203. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
  204. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
  205. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
  206. package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
  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 +257 -77
  209. package/node_modules/pi-lens/dist/clients/runtime-context.js +129 -12
  210. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +242 -26
  211. package/node_modules/pi-lens/dist/clients/runtime-session.js +428 -117
  212. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
  213. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +342 -33
  214. package/node_modules/pi-lens/dist/clients/runtime-turn.js +619 -93
  215. package/node_modules/pi-lens/dist/clients/rust-client.js +23 -38
  216. package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
  217. package/node_modules/pi-lens/dist/clients/security-scan-client.js +90 -6
  218. package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
  219. package/node_modules/pi-lens/dist/clients/sg-runner.js +424 -196
  220. package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
  221. package/node_modules/pi-lens/dist/clients/smells-rollup.js +231 -0
  222. package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
  223. package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
  224. package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
  225. package/node_modules/pi-lens/dist/clients/startup-timing.js +7 -1
  226. package/node_modules/pi-lens/dist/clients/test-runner-client.js +469 -32
  227. package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
  228. package/node_modules/pi-lens/dist/clients/tool-policy.js +390 -39
  229. package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
  230. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  231. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
  232. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
  233. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
  234. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
  235. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
  236. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
  237. package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
  238. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
  239. package/node_modules/pi-lens/dist/clients/warm-attach.js +50 -13
  240. package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
  241. package/node_modules/pi-lens/dist/clients/word-index.js +680 -81
  242. package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
  243. package/node_modules/pi-lens/dist/index.js +51785 -36118
  244. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
  245. package/node_modules/pi-lens/dist/mcp/server.js +219 -46
  246. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  247. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  248. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
  249. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  250. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +203 -38
  251. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +210 -42
  252. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
  253. package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
  254. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  255. package/node_modules/pi-lens/docs/agent-guide.md +40 -15
  256. package/node_modules/pi-lens/docs/agent-tools.md +21 -10
  257. package/node_modules/pi-lens/docs/analysisall.md +192 -0
  258. package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
  259. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +14 -3
  260. package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
  261. package/node_modules/pi-lens/docs/environment-variables.md +44 -0
  262. package/node_modules/pi-lens/docs/fable.md +27 -0
  263. package/node_modules/pi-lens/docs/features.md +45 -3
  264. package/node_modules/pi-lens/docs/globalconfig.md +56 -1
  265. package/node_modules/pi-lens/docs/language-coverage.md +4 -3
  266. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
  267. package/node_modules/pi-lens/docs/mcp.md +37 -4
  268. package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
  269. package/node_modules/pi-lens/docs/settings.md +18 -1
  270. package/node_modules/pi-lens/docs/tools.md +4 -1
  271. package/node_modules/pi-lens/docs/usage.md +41 -5
  272. package/node_modules/pi-lens/docs/word-index.md +35 -0
  273. package/node_modules/pi-lens/package.json +10 -10
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  288. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  289. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
  290. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  291. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  292. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  293. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  294. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
  295. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
  296. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
  297. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
  298. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
  299. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  300. package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  301. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
  302. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +134 -2
  303. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
  304. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  305. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
  306. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
  307. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  308. package/node_modules/pi-mcp-adapter/CHANGELOG.md +38 -0
  309. package/node_modules/pi-mcp-adapter/README.md +38 -4
  310. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  311. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
  312. package/node_modules/pi-mcp-adapter/cli.js +25 -4
  313. package/node_modules/pi-mcp-adapter/commands.ts +15 -7
  314. package/node_modules/pi-mcp-adapter/config.ts +4 -4
  315. package/node_modules/pi-mcp-adapter/direct-tools.ts +58 -8
  316. package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
  317. package/node_modules/pi-mcp-adapter/index.ts +8 -3
  318. package/node_modules/pi-mcp-adapter/init.ts +29 -5
  319. package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
  320. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  321. package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
  322. package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
  323. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
  324. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  325. package/node_modules/pi-mcp-adapter/metadata-cache.ts +37 -4
  326. package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
  327. package/node_modules/pi-mcp-adapter/package.json +4 -3
  328. package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
  329. package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
  330. package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
  331. package/node_modules/pi-mcp-adapter/server-manager.ts +41 -7
  332. package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
  333. package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
  334. package/node_modules/pi-mcp-adapter/tool-metadata.ts +62 -4
  335. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
  336. package/node_modules/pi-mcp-adapter/types.ts +176 -27
  337. package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
  338. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  339. package/node_modules/pi-web-access/README.md +18 -12
  340. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  341. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  342. package/node_modules/pi-web-access/curator-page.ts +5 -3
  343. package/node_modules/pi-web-access/curator-server.ts +2 -1
  344. package/node_modules/pi-web-access/extract.ts +106 -34
  345. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  346. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  347. package/node_modules/pi-web-access/gemini-search.ts +18 -4
  348. package/node_modules/pi-web-access/index.ts +120 -48
  349. package/node_modules/pi-web-access/package.json +2 -2
  350. package/node_modules/pi-web-access/summary-review.ts +11 -5
  351. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  352. package/package.json +12 -13
  353. package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
  354. package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
  355. package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
  356. package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
  357. package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
  358. package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
  359. package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
  360. package/node_modules/pi-lens/dist/clients/types.js +0 -11
@@ -11,25 +11,69 @@ import { createHash } from "node:crypto";
11
11
  import * as nodeFs from "node:fs";
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
+ import { pathToFileURL, URL } from "node:url";
14
15
  import { getProjectIgnoreMatcher, isExcludedDirName, } from "../file-utils.js";
15
16
  import { recordLsp } from "../widget-state.js";
16
17
  import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
17
18
  import { detectFileRole } from "../file-role.js";
18
19
  import { logLatency } from "../latency-logger.js";
19
20
  import { logSessionStart } from "../sessionstart-logger.js";
21
+ import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "../degradation-ledger.js";
22
+ import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
20
23
  import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
21
24
  import { withDeadline } from "../deadline-utils.js";
22
- import { isAtOrAboveHomeDir, normalizeMapKey, uriToPath, } from "../path-utils.js";
25
+ import { isAtOrAboveHomeDir, isWindowsPath, normalizeMapKey, uriToPath, } from "../path-utils.js";
26
+ import { recordLspMutation, } from "../lsp-mutation.js";
23
27
  import { createLSPClient } from "./client.js";
28
+ import { bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, touchCoverageGap, } from "./diagnostic-binding.js";
24
29
  import { getServersForFileWithConfig, getServerInitOverride } from "./config.js";
25
30
  import { getLanguageId } from "./language.js";
26
- import { LSP_SERVERS, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
31
+ import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
27
32
  import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
28
33
  import { raceToCompletion } from "./aggregation.js";
29
- import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, } from "./edits.js";
34
+ import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, validateWorkspaceEdit, } from "./edits.js";
30
35
  import { buildScopeKey, createWorkspaceDiagnosticsCacheContext, } from "./workspace-diagnostics-cache.js";
31
36
  import { attemptTsserverSyncDiagnostics, } from "./tsserver-sync.js";
32
37
  import { isWarmAttached, tryWarmAttachedDiagnostics, } from "../warm-attach.js";
38
+ function destinationUriPreservingSpelling(oldUri, oldFilePath, newFilePath) {
39
+ const canonical = pathToFileURL(newFilePath);
40
+ try {
41
+ const original = new URL(oldUri);
42
+ const authority = /^file:\/\/([^/]*)/i.exec(oldUri)?.[1] ?? "";
43
+ if (original.protocol !== "file:" ||
44
+ (original.host !== "" && original.hostname !== "localhost")) {
45
+ return canonical.href;
46
+ }
47
+ const canonicalDestination = () => `${original.protocol}//${authority}${canonical.pathname}`;
48
+ const oldParent = path.resolve(path.dirname(oldFilePath));
49
+ const newParent = path.resolve(path.dirname(newFilePath));
50
+ if (normalizeMapKey(oldParent) !== normalizeMapKey(newParent)) {
51
+ return canonicalDestination();
52
+ }
53
+ const slash = original.pathname.lastIndexOf("/");
54
+ if (slash < 0)
55
+ return canonicalDestination();
56
+ let rawName = canonical.pathname.slice(canonical.pathname.lastIndexOf("/") + 1);
57
+ const oldRawName = original.pathname.slice(slash + 1);
58
+ const oldName = decodeURIComponent(oldRawName);
59
+ const expectedOldName = path.basename(oldFilePath);
60
+ if (oldName.toLowerCase() !== expectedOldName.toLowerCase()) {
61
+ return canonicalDestination();
62
+ }
63
+ // Keep non-canonical percent-encoding choices from the URI that was
64
+ // opened (for example `%2E` instead of `.`) while using the new path's
65
+ // exact basename. The authority and directory spelling are preserved too.
66
+ for (const match of oldRawName.matchAll(/%([0-9a-f]{2})/gi)) {
67
+ const encoded = match[0];
68
+ const decoded = String.fromCharCode(Number.parseInt(match[1], 16));
69
+ rawName = rawName.split(decoded).join(encoded);
70
+ }
71
+ return `${original.protocol}//${authority}${original.pathname.slice(0, slash + 1)}${rawName}`;
72
+ }
73
+ catch {
74
+ return canonical.href;
75
+ }
76
+ }
33
77
  // --- Init override helpers ---
34
78
  /**
35
79
  * Recursively merges `override` onto `base`. Override wins on leaf conflicts
@@ -69,6 +113,41 @@ export function mergeInitializationOptions(base, override) {
69
113
  const BROKEN_BASE_COOLDOWN_MS = 15_000;
70
114
  const BROKEN_MAX_COOLDOWN_MS = 5 * 60_000; // cap at 5 minutes
71
115
  const BROKEN_PERMANENT_AFTER = 5; // disable for session after N consecutive failures
116
+ // #1127: a client that dies THIS soon after a successful spawn/initialize is
117
+ // treated as a crash-loop symptom (opengrep's post-init "Unhandled message"
118
+ // exit), not a legitimate long-running server that happened to restart once.
119
+ // 60s comfortably covers normal serve-a-few-files-then-idle sessions while
120
+ // still catching "spawns fine, dies within seconds every time" servers.
121
+ const RUNTIME_EXIT_UPTIME_THRESHOLD_MS = 60_000;
122
+ // #1142: the fast path above only trips on CONSECUTIVE exits UNDER the 60s
123
+ // threshold, so a server that reliably dies just PAST it (~65-90s after every
124
+ // spawn) resets that streak on every death and churns forever — a persistent
125
+ // low-frequency crash loop the hard-cutoff design structurally cannot see. The
126
+ // windowed-rate trip COMPOSES with (does not replace) the fast path: N
127
+ // non-intentional deaths within a rolling M-minute window trip the breaker
128
+ // regardless of each death's individual lifetime.
129
+ // N = BROKEN_PERMANENT_AFTER (5): one uniform "5 strikes" give-up count
130
+ // across both streams, and high enough that the benign over-threshold
131
+ // deaths that reach this path (a one-off crash after a long healthy run;
132
+ // a single sleep/resume connection drop) — at most one per key per event
133
+ // — cannot approach it.
134
+ // M = 15 min: wide enough for a genuine slow loop (each life ~65-90s, so 5
135
+ // death→respawn cycles land in ~6-15 min of active churn) to accumulate
136
+ // 5, yet short enough that sparse benign crashes (e.g. "twice last hour,
137
+ // now healthy") age out of the rolling window before five ever coincide.
138
+ const RUNTIME_EXIT_WINDOW_MS = 15 * 60_000;
139
+ const RUNTIME_EXIT_WINDOW_TRIP_COUNT = BROKEN_PERMANENT_AFTER;
140
+ // A death whose measured lifetime (`exitedAt - spawnedAt`) exceeds this ceiling
141
+ // is NOT recorded toward the window: it is either a genuinely long healthy run
142
+ // that crashed once, or a lifetime computed ACROSS a machine-sleep / Modern-
143
+ // Standby suspend (the #1122/#1139 death-timestamp lesson — a suspend gap makes
144
+ // a benign exit look like a huge "uptime"), and neither is crash-loop churn.
145
+ // The slow-loop residual this fix targets dies well within the ceiling
146
+ // (~65-90s), so it is unaffected. This is a belt-and-suspenders guard on top of
147
+ // the structural defense that the window is per-KEY: a single suspend kills at
148
+ // most one live client per server, contributing at most one death to any one
149
+ // key's window, so it cannot by itself reach the trip count regardless.
150
+ const RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS = 10 * 60_000;
72
151
  // #743: a server whose per-server notify write (didOpen/didChange) times out
73
152
  // this many times in a row is a persistently backpressured server; it is demoted
74
153
  // into the `broken` cooldown map (evicted + cooled down) so subsequent sweeps
@@ -81,6 +160,20 @@ const NAV_CLIENT_WAIT_TIMEOUT_MS = Math.max(0, Number.parseInt(process.env.PI_LE
81
160
  1500);
82
161
  const TOUCH_DEBOUNCE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_TOUCH_DEBOUNCE_MS ?? "1500", 10) ||
83
162
  1500);
163
+ const DEFAULT_LSP_CLIENT_CEILING = 24;
164
+ const DEFAULT_TS_IDLE_EVICT_MS = 20 * 60_000;
165
+ export function getTypeScriptIdleEvictMs() {
166
+ const parsed = Number.parseInt(process.env.PI_LENS_TS_IDLE_EVICT_MS ?? "", 10);
167
+ return Number.isSafeInteger(parsed) && parsed > 0
168
+ ? parsed
169
+ : DEFAULT_TS_IDLE_EVICT_MS;
170
+ }
171
+ export function getLspClientCeiling() {
172
+ const parsed = Number.parseInt(process.env.PI_LENS_LSP_CLIENT_CEILING ?? "", 10);
173
+ return Number.isSafeInteger(parsed) && parsed > 0
174
+ ? parsed
175
+ : DEFAULT_LSP_CLIENT_CEILING;
176
+ }
84
177
  // #667: the sweep warm-up round trip's OWN generous, one-time budget —
85
178
  // deliberately larger than any single per-file sweep budget (`perFileMs` in
86
179
  // `runWorkspaceDiagnostics`, or the batch tool's per-file wait) because this
@@ -137,13 +230,15 @@ function readTsserverSyncGraceMs() {
137
230
  }
138
231
  /**
139
232
  * Read the `PI_LENS_AUX_GRACE_MS` env override at call time (not module
140
- * load time) so tests can set it per-case. Controls how long auxiliary-role
141
- * promises (opengrep, ast-grep, zizmor, …) are waited after all primary-role
142
- * promises have settled in both getDiagnostics (raceToCompletion) and the
143
- * touchFile push wait. Default 500ms — conservative enough to include
144
- * auxiliaries that are nearly done while not blocking the primary result.
145
- * Returns undefined when the var is absent (caller falls back to the
146
- * raceToCompletion default of 500ms, keeping the two in sync).
233
+ * load time) so tests can set it per-case. Controls the CEILING on how long
234
+ * auxiliary-role promises (opengrep, ast-grep, zizmor, …) are waited after
235
+ * all primary-role promises have settled, in both getDiagnostics
236
+ * (raceToCompletion) and the touchFile push wait (#1458 S2 — the two lanes
237
+ * share the same declared-budget-capped-by-ceiling shape). Each auxiliary
238
+ * still gets only its OWN declared `aggregateWaitMs` up to this ceiling —
239
+ * this is not a flat per-touch wait. Returns undefined when the var is
240
+ * absent; each call site then supplies its own default ceiling (touchFile:
241
+ * 2000ms; getDiagnostics: 2000ms — see the `?? 2000` at each call site).
147
242
  */
148
243
  function readEnvAuxGraceMs() {
149
244
  const raw = process.env.PI_LENS_AUX_GRACE_MS;
@@ -279,7 +374,7 @@ const WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE = (() => {
279
374
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 8;
280
375
  })();
281
376
  // #584: opengrep has no `workspace/diagnostic` pull support (push-only,
282
- // docs/servercapabilities.md) and `reopenOnResync: true` (server-strategies.ts)
377
+ // docs/servercapabilities.md) and `reopenOnResync: true` (wait-policy/strategies.ts)
283
378
  // means every per-file LSP touch already forces a full re-scan anyway — there's
284
379
  // no incremental win from routing it through the sweep's per-file loop. On a
285
380
  // full workspace sweep it instead dominates the per-file wait (its own
@@ -390,11 +485,50 @@ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceD
390
485
  export class LSPService {
391
486
  state;
392
487
  workspaceProbeLogged = new Set();
488
+ /** Per-service immutable root-boundary verdicts; root detectors cache hits too. */
489
+ projectBoundaryCache = new Map();
393
490
  warmStartLogged = new Set();
394
491
  optionalFailureLogged = new Set();
492
+ /** Server/root pairs that already emitted unavailable for the current occurrence. */
493
+ unavailableLogged = new Set();
395
494
  optionalDisabled = new Set();
396
495
  /** Consecutive failure counts for exponential backoff circuit breaker */
397
496
  failureCounts = new Map();
497
+ /**
498
+ * #1127: consecutive EARLY post-init runtime exits — a client that closed
499
+ * unexpectedly (not via our own `shutdown()`) with an uptime under
500
+ * {@link RUNTIME_EXIT_UPTIME_THRESHOLD_MS}. Tracked separately from
501
+ * {@link failureCounts} because a successful spawn/initialize clears that
502
+ * map (correct for the spawn/init failure class it was built for — see
503
+ * `spawnClient`'s success path) but is exactly the event a crash-loop
504
+ * server keeps producing right before it dies again; reusing the same map
505
+ * would make every respawn erase the streak the moment it re-spawned,
506
+ * which is the #1127 bug. This counter shares the SAME cooldown formula,
507
+ * the SAME `state.broken`/`permanentlyBroken` maps, and the SAME
508
+ * give-up-after-N-failures threshold as the spawn/init breaker — it is a
509
+ * parallel counter feeding one circuit, not a second mechanism.
510
+ */
511
+ runtimeExitCounts = new Map();
512
+ /**
513
+ * #1142: rolling window of recent non-intentional death timestamps
514
+ * (`exitedAt`) per "serverId:root" key (the SAME identity as
515
+ * {@link runtimeExitCounts} and {@link LSPState.broken} — defect shape 1) —
516
+ * the SECOND, independent breaker trip condition that COMPOSES with the
517
+ * {@link runtimeExitCounts} fast path. That counter only sees CONSECUTIVE
518
+ * exits under the 60s threshold; a server that dies just PAST it every spawn
519
+ * resets that streak forever (#1142). This window trips when
520
+ * {@link RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths fall within
521
+ * {@link RUNTIME_EXIT_WINDOW_MS}, regardless of each death's lifetime.
522
+ *
523
+ * Bounded on BOTH axes (defect shape 9): entries older than the window are
524
+ * pruned on every record, AND the array is hard-capped at the trip count
525
+ * (drop-oldest) — a rate trip needs only the count within the window, never
526
+ * an unbounded history of timestamps. No timer backs it (defect shape 4): it
527
+ * ages purely by prune-on-check, so there is nothing to `unref()` or
528
+ * print-mode-gate. Like {@link runtimeExitCounts} it is a per-instance field,
529
+ * so a fresh `LSPService` (via `resetLSPService`) starts empty.
530
+ */
531
+ runtimeExitWindow = new Map();
398
532
  /** Server/root keys disabled for the rest of this session after repeated failures. */
399
533
  permanentlyBroken = new Set();
400
534
  /**
@@ -413,7 +547,26 @@ export class LSPService {
413
547
  * those falls through to a fresh check rather than serving a stale result.
414
548
  */
415
549
  lastKnownContentHash = new Map();
550
+ /**
551
+ * #1095: lazily verifies a stored {@link DiagnosticBinding} against current
552
+ * disk bytes, memoizing the disk fingerprint per (file, mtime) so repeated
553
+ * binding reads across `touchFile`/`getAllDiagnostics` within a session don't
554
+ * re-hash unchanged files. Owned by the service so the memo is shared.
555
+ */
556
+ diskBindingCache = createDiskBindingCache();
416
557
  lastDiagnosticsHealth = new Map();
558
+ /**
559
+ * Touch-debounce record of "this server already has this content", keyed
560
+ * "normalizedFilePath:clientScope:serverId".
561
+ *
562
+ * #743/#1253: the serverId component is load-bearing. #1253 requires that a
563
+ * server whose notify write never landed does NOT get an entry (otherwise the
564
+ * next touch debounces the re-push and the silent-clean gates read its silence
565
+ * as a confirmed clean). #743 requires that one wedged server must not degrade
566
+ * its healthy siblings. A file-level key can only satisfy one of those at a
567
+ * time — per-server, both hold: the stalled server simply has no entry and is
568
+ * re-pushed, while every sibling whose write landed keeps its own debounce.
569
+ */
417
570
  recentTouches = new Map();
418
571
  /**
419
572
  * #743: consecutive per-server notify-write timeout count, keyed by
@@ -424,6 +577,23 @@ export class LSPService {
424
577
  * successful write clears its entry.
425
578
  */
426
579
  notifyWriteBackpressureStreak = new Map();
580
+ /** LRU clock for capacity eviction, keyed by the canonical server/root key. */
581
+ clientLastUsedAt = new Map();
582
+ /**
583
+ * Manager-owned use leases close the acquisition/use gap that `isBusy()`
584
+ * cannot see: a caller may hold a selected client before its first request has
585
+ * entered the client. Eviction skips any key with an outstanding lease.
586
+ */
587
+ clientLeases = new Map();
588
+ /**
589
+ * Per-root idle eviction for TypeScript's large, rebuildable program graph.
590
+ * Timers are unref'd so an idle language service cannot keep a one-shot host
591
+ * alive, and are removed before shutdown so a concurrent request rebuilds
592
+ * instead of receiving the retiring client.
593
+ */
594
+ typeScriptIdleTimers = new Map();
595
+ /** Serializes capacity decisions with publication into `state.inFlight`. */
596
+ clientSpawnGate = Promise.resolve();
427
597
  /** True after shutdown() has been called; blocks new operations */
428
598
  isDestroyed = false;
429
599
  /**
@@ -446,10 +616,218 @@ export class LSPService {
446
616
  demonstratedCold: new Set(),
447
617
  };
448
618
  }
619
+ /**
620
+ * Resolve one server's client identity root. Root selection is hard-bounded
621
+ * by the session cwd, then config-only nested roots reuse an already-hosted
622
+ * same-server ancestor. Real nested projects retain independent clients.
623
+ */
624
+ async resolveServerRoot(server, filePath) {
625
+ const candidate = await server.root(filePath);
626
+ if (!candidate)
627
+ return undefined;
628
+ const root = enforceLspRootCeiling(candidate, process.cwd(), filePath);
629
+ if (normalizeMapKey(root) === normalizeMapKey(process.cwd()))
630
+ return root;
631
+ const rootKey = normalizeMapKey(root);
632
+ const prefix = `${server.id}:`;
633
+ let nearestAncestor;
634
+ for (const key of new Set([
635
+ ...this.state.clients.keys(),
636
+ ...this.state.inFlight.keys(),
637
+ ])) {
638
+ if (!key.startsWith(prefix))
639
+ continue;
640
+ const ancestorKey = key.slice(prefix.length);
641
+ const pathApi = isWindowsPath(ancestorKey) || isWindowsPath(rootKey)
642
+ ? path.win32
643
+ : path;
644
+ const relative = pathApi.relative(ancestorKey, rootKey);
645
+ if (relative === "" ||
646
+ relative.startsWith("..") ||
647
+ pathApi.isAbsolute(relative)) {
648
+ continue;
649
+ }
650
+ if (!nearestAncestor || ancestorKey.length > nearestAncestor.length) {
651
+ nearestAncestor = ancestorKey;
652
+ }
653
+ }
654
+ if (!nearestAncestor)
655
+ return root;
656
+ let boundary = this.projectBoundaryCache.get(rootKey);
657
+ if (!boundary) {
658
+ boundary = hasProjectBoundaryMarker(root);
659
+ this.projectBoundaryCache.set(rootKey, boundary);
660
+ }
661
+ if (await boundary)
662
+ return root;
663
+ return (this.state.clients.get(`${server.id}:${nearestAncestor}`)?.root ??
664
+ nearestAncestor);
665
+ }
449
666
  /** Guard: return true if service is shutting down or shut down */
450
667
  checkDestroyed() {
451
668
  return this.isDestroyed;
452
669
  }
670
+ async withClientSpawnGate(operation) {
671
+ const previous = this.clientSpawnGate;
672
+ let release;
673
+ this.clientSpawnGate = new Promise((resolve) => {
674
+ release = resolve;
675
+ });
676
+ await previous;
677
+ try {
678
+ return await operation();
679
+ }
680
+ finally {
681
+ release();
682
+ }
683
+ }
684
+ async clientKeyFor(entry, filePath) {
685
+ const root = await this.resolveServerRoot(entry.info, filePath);
686
+ return root ? `${entry.info.id}:${normalizeMapKey(root)}` : undefined;
687
+ }
688
+ async acquireClientLease(entry, filePath) {
689
+ const key = await this.clientKeyFor(entry, filePath);
690
+ if (!key)
691
+ return undefined;
692
+ return this.withClientSpawnGate(async () => {
693
+ if (this.isDestroyed || this.state.clients.get(key) !== entry.client) {
694
+ return undefined;
695
+ }
696
+ this.clientLeases.set(key, (this.clientLeases.get(key) ?? 0) + 1);
697
+ return key;
698
+ });
699
+ }
700
+ async acquireClientLeases(entries, filePath) {
701
+ const keys = await Promise.all(entries.map((entry) => this.clientKeyFor(entry, filePath)));
702
+ if (keys.some((key) => key === undefined))
703
+ return undefined;
704
+ const keyed = entries.map((entry, index) => [keys[index], entry]);
705
+ return this.withClientSpawnGate(async () => {
706
+ if (this.isDestroyed ||
707
+ keyed.some(([key, entry]) => this.state.clients.get(key) !== entry.client)) {
708
+ return undefined;
709
+ }
710
+ for (const [key] of keyed) {
711
+ this.clientLeases.set(key, (this.clientLeases.get(key) ?? 0) + 1);
712
+ }
713
+ return keyed.map(([key]) => key);
714
+ });
715
+ }
716
+ releaseClientLease(key) {
717
+ const remaining = (this.clientLeases.get(key) ?? 1) - 1;
718
+ if (remaining > 0) {
719
+ this.clientLeases.set(key, remaining);
720
+ return;
721
+ }
722
+ this.clientLeases.delete(key);
723
+ if (this.state.clients.has(key)) {
724
+ this.clientLastUsedAt.set(key, Date.now());
725
+ this.scheduleTypeScriptIdleEviction(key);
726
+ }
727
+ }
728
+ async withClientForFileUse(filePath, maxWaitMs, hardCapMs, use) {
729
+ while (!this.isDestroyed) {
730
+ const entry = await this.getClientForFile(filePath, maxWaitMs, hardCapMs);
731
+ if (!entry)
732
+ return undefined;
733
+ const leaseKey = await this.acquireClientLease(entry, filePath);
734
+ if (!leaseKey)
735
+ continue;
736
+ try {
737
+ return await use(entry);
738
+ }
739
+ finally {
740
+ this.releaseClientLease(leaseKey);
741
+ }
742
+ }
743
+ return undefined;
744
+ }
745
+ async makeCapacityForClient(key) {
746
+ const occupiedKeys = new Set(this.state.inFlight.keys());
747
+ for (const [clientKey, client] of this.state.clients) {
748
+ if (client.isAlive())
749
+ occupiedKeys.add(clientKey);
750
+ }
751
+ const occupied = occupiedKeys.size;
752
+ if (occupied < getLspClientCeiling())
753
+ return true;
754
+ const idle = [...this.state.clients.entries()]
755
+ .filter(([candidateKey, client]) => candidateKey !== key &&
756
+ client.isAlive() &&
757
+ client.isBusy?.() !== true &&
758
+ (this.clientLeases.get(candidateKey) ?? 0) === 0)
759
+ .sort(([a], [b]) => (this.clientLastUsedAt.get(a) ?? this.state.clientSpawnedAt.get(a) ?? 0) -
760
+ (this.clientLastUsedAt.get(b) ?? this.state.clientSpawnedAt.get(b) ?? 0));
761
+ const victim = idle[0];
762
+ if (!victim) {
763
+ logSessionStart(`lsp client ceiling ${getLspClientCeiling()}: spawn declined; all clients are in use`);
764
+ return false;
765
+ }
766
+ const [victimKey, victimClient] = victim;
767
+ await victimClient.shutdown({ reason: "client_ceiling_lru" });
768
+ this.state.clients.delete(victimKey);
769
+ this.state.clientSpawnedAt.delete(victimKey);
770
+ this.state.demonstratedReady.delete(victimKey);
771
+ this.state.demonstratedCold.delete(victimKey);
772
+ this.clientLastUsedAt.delete(victimKey);
773
+ this.clearTypeScriptIdleTimer(victimKey);
774
+ logSessionStart(`lsp client ceiling ${getLspClientCeiling()}: evicted idle LRU ${victimKey}`);
775
+ return true;
776
+ }
777
+ clearTypeScriptIdleTimer(key) {
778
+ const timer = this.typeScriptIdleTimers.get(key);
779
+ if (timer)
780
+ clearTimeout(timer);
781
+ this.typeScriptIdleTimers.delete(key);
782
+ }
783
+ scheduleTypeScriptIdleEviction(key) {
784
+ if (!key.startsWith("typescript:"))
785
+ return;
786
+ // Pressure-gating these timers would require a separate reconciliation pass
787
+ // when the manager crosses the threshold; keep ownership simple and use the
788
+ // warm-LSP-friendly 20-minute default instead.
789
+ this.clearTypeScriptIdleTimer(key);
790
+ const lastUsedAt = this.clientLastUsedAt.get(key) ?? Date.now();
791
+ const timer = setTimeout(() => {
792
+ this.typeScriptIdleTimers.delete(key);
793
+ void this.withClientSpawnGate(async () => {
794
+ if (this.isDestroyed)
795
+ return;
796
+ const client = this.state.clients.get(key);
797
+ if (!client?.isAlive())
798
+ return;
799
+ if (client.isBusy?.() === true ||
800
+ (this.clientLeases.get(key) ?? 0) > 0 ||
801
+ (this.clientLastUsedAt.get(key) ?? 0) !== lastUsedAt) {
802
+ this.scheduleTypeScriptIdleEviction(key);
803
+ return;
804
+ }
805
+ // Publish the cold state synchronously before awaiting teardown. A request
806
+ // arriving while shutdown is in progress therefore waits on the spawn gate
807
+ // and creates a fresh client; it can never receive this retiring one.
808
+ this.state.clients.delete(key);
809
+ this.state.clientSpawnedAt.delete(key);
810
+ this.state.demonstratedReady.delete(key);
811
+ this.state.demonstratedCold.delete(key);
812
+ this.clientLastUsedAt.delete(key);
813
+ try {
814
+ await client.shutdown({ reason: "typescript_idle_eviction" });
815
+ }
816
+ catch {
817
+ // The strong manager reference is already gone; shutdown is best-effort
818
+ // like the other eviction paths and must not reject from a timer callback.
819
+ }
820
+ logSessionStart(`lsp typescript idle eviction: released ${key}`);
821
+ recordDegradation({
822
+ kind: "ts-idle-eviction",
823
+ subject: key,
824
+ reason: "idle TypeScript client released to bound memory",
825
+ });
826
+ }).catch(() => { });
827
+ }, getTypeScriptIdleEvictMs());
828
+ timer.unref?.();
829
+ this.typeScriptIdleTimers.set(key, timer);
830
+ }
453
831
  fingerprintContent(content) {
454
832
  if (content.length <= 96) {
455
833
  return `${content.length}:${content}`;
@@ -461,10 +839,15 @@ export class LSPService {
461
839
  * NOT need diagnostics — those callers still need to wait for the LSP to
462
840
  * publish, even if the notify itself is a no-op.
463
841
  */
464
- shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics) {
842
+ shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics, serverIds) {
465
843
  if (waitForDiagnostics)
466
844
  return false;
467
- return this.shouldSkipNotify(filePath, content, clientScope);
845
+ // #743: only short-circuit the whole call when EVERY spawned server already
846
+ // has this content. If even one still needs the push, fall through — the
847
+ // write loop skips the servers that are covered and pushes only the rest.
848
+ if (serverIds.length === 0)
849
+ return false;
850
+ return serverIds.every((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId));
468
851
  }
469
852
  /**
470
853
  * Should the didOpen/didChange notify be skipped while keeping the
@@ -478,12 +861,14 @@ export class LSPService {
478
861
  * with diagnosticsMode="document" moments later. Without this check the
479
862
  * second call's notify clears in-progress diagnostics and the LSP has to
480
863
  * start over — observed as multi-second waits on slow TS projects.
864
+ *
865
+ * Decided PER SERVER (#743): a sibling's stalled write must not cost this
866
+ * server its debounce. See {@link recentTouches}.
481
867
  */
482
- shouldSkipNotify(filePath, content, clientScope) {
868
+ shouldSkipNotify(filePath, content, clientScope, serverId) {
483
869
  if (TOUCH_DEBOUNCE_MS <= 0)
484
870
  return false;
485
- const key = `${normalizeMapKey(filePath)}:${clientScope}`;
486
- const previous = this.recentTouches.get(key);
871
+ const previous = this.recentTouches.get(this.recentTouchKey(filePath, clientScope, serverId));
487
872
  if (!previous)
488
873
  return false;
489
874
  const now = Date.now();
@@ -491,8 +876,11 @@ export class LSPService {
491
876
  return false;
492
877
  return previous.fingerprint === this.fingerprintContent(content);
493
878
  }
494
- markTouched(filePath, content, clientScope) {
495
- const key = `${normalizeMapKey(filePath)}:${clientScope}`;
879
+ recentTouchKey(filePath, clientScope, serverId) {
880
+ return `${normalizeMapKey(filePath)}:${clientScope}:${serverId}`;
881
+ }
882
+ markTouched(filePath, content, clientScope, serverId) {
883
+ const key = this.recentTouchKey(filePath, clientScope, serverId);
496
884
  const now = Date.now();
497
885
  this.recentTouches.set(key, {
498
886
  fingerprint: this.fingerprintContent(content),
@@ -501,8 +889,10 @@ export class LSPService {
501
889
  });
502
890
  // Trim entries that are already past the debounce window — shouldSkipTouch
503
891
  // ignores them anyway, so they serve no purpose. Only sweep when the map
504
- // exceeds the threshold to avoid iterating on every call.
505
- if (this.recentTouches.size > 200) {
892
+ // exceeds the threshold to avoid iterating on every call. The threshold is
893
+ // per-ENTRY and entries are now per-server, so it is scaled up to keep the
894
+ // same effective file capacity a file-level key gave a multi-server scope.
895
+ if (this.recentTouches.size > 800) {
506
896
  for (const [k, v] of this.recentTouches) {
507
897
  if (now - v.touchedAt > TOUCH_DEBOUNCE_MS) {
508
898
  this.recentTouches.delete(k);
@@ -522,7 +912,7 @@ export class LSPService {
522
912
  * implementation that doesn't independently stamp `.root`).
523
913
  */
524
914
  async demonstratedReadyKeyFor(server, filePath) {
525
- const root = await server.root(filePath);
915
+ const root = await this.resolveServerRoot(server, filePath);
526
916
  if (!root)
527
917
  return undefined;
528
918
  return `${server.id}:${normalizeMapKey(root)}`;
@@ -534,6 +924,9 @@ export class LSPService {
534
924
  // the negative cache.
535
925
  this.state.demonstratedCold.delete(key);
536
926
  }
927
+ recordBreaker(key, reason) {
928
+ recordDegradationOnce({ kind: "lsp-breaker", subject: key, reason });
929
+ }
537
930
  /**
538
931
  * #743: record one notify-write timeout for a server and, once it has stalled
539
932
  * {@link NOTIFY_BACKPRESSURE_BROKEN_AFTER} times in a row, demote it through
@@ -558,6 +951,8 @@ export class LSPService {
558
951
  this.state.clients.delete(key);
559
952
  this.state.clientSpawnedAt.delete(key);
560
953
  this.state.demonstratedReady.delete(key);
954
+ this.clientLastUsedAt.delete(key);
955
+ this.clearTypeScriptIdleTimer(key);
561
956
  logLatency({
562
957
  type: "phase",
563
958
  phase: "lsp_notify_backpressure_broken",
@@ -633,14 +1028,25 @@ export class LSPService {
633
1028
  return spawned;
634
1029
  }
635
1030
  }
1031
+ const unavailable = (await Promise.all(servers.map(async (server) => {
1032
+ const root = await server.root(filePath);
1033
+ return {
1034
+ server,
1035
+ key: `${server.id}:${root ? normalizeMapKey(root) : "<unresolved>"}`,
1036
+ };
1037
+ }))).filter(({ key }) => !this.unavailableLogged.has(key));
1038
+ for (const { key } of unavailable)
1039
+ this.unavailableLogged.add(key);
1040
+ if (unavailable.length === 0)
1041
+ return undefined;
636
1042
  logLatency({
637
1043
  type: "phase",
638
1044
  phase: "lsp_client_unavailable",
639
1045
  filePath,
640
1046
  durationMs: 0,
641
1047
  metadata: {
642
- candidateCount: servers.length,
643
- servers: servers.map((server) => server.id),
1048
+ candidateCount: unavailable.length,
1049
+ servers: unavailable.map(({ server }) => server.id),
644
1050
  },
645
1051
  });
646
1052
  return undefined;
@@ -649,10 +1055,31 @@ export class LSPService {
649
1055
  return withBudget();
650
1056
  }
651
1057
  const timeoutSentinel = Symbol("lsp-client-wait-timeout");
652
- const waitResult = await Promise.race([
653
- withBudget(),
654
- new Promise((resolve) => setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs)),
655
- ]);
1058
+ // #1097: store the timer and clear it once the race settles. Without this,
1059
+ // when `withBudget()` wins (the common case: the client is ready well before
1060
+ // the budget), the losing `setTimeout` stays a REF'D pending timer for the
1061
+ // full remaining `effectiveMaxWaitMs`. In a long-lived interactive session
1062
+ // that is invisible (it fires later, resolves an orphan promise, is GC'd).
1063
+ // In a one-shot `pi --print --no-session` process it is fatal: the timer
1064
+ // keeps the event loop alive for up to `effectiveMaxWaitMs` after
1065
+ // `agent_settled`/`session_shutdown`, so the completed process never exits
1066
+ // (issue #1097 — a recurrence of the #22 symptom via a different handle, and
1067
+ // a member of the uncleared-race-timeout class the shared `withDeadline`
1068
+ // helper already guards against elsewhere).
1069
+ let waitTimer;
1070
+ let waitResult;
1071
+ try {
1072
+ waitResult = await Promise.race([
1073
+ withBudget(),
1074
+ new Promise((resolve) => {
1075
+ waitTimer = setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs);
1076
+ }),
1077
+ ]);
1078
+ }
1079
+ finally {
1080
+ if (waitTimer)
1081
+ clearTimeout(waitTimer);
1082
+ }
656
1083
  if (waitResult === timeoutSentinel) {
657
1084
  // Snapshot known client health — scan by serverId prefix (no root needed)
658
1085
  const knownHealth = [...this.state.clients.entries()]
@@ -691,7 +1118,7 @@ export class LSPService {
691
1118
  return { clients: [], serverCountAttempted: 0 };
692
1119
  // Count servers with a valid root as "attempted" — extension-only matches
693
1120
  // that fail the root check are not real spawn attempts.
694
- const roots = await Promise.all(servers.map((s) => s.root(filePath)));
1121
+ const roots = await Promise.all(servers.map((s) => this.resolveServerRoot(s, filePath)));
695
1122
  const serverCountAttempted = roots.filter(Boolean).length;
696
1123
  const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
697
1124
  return {
@@ -722,7 +1149,7 @@ export class LSPService {
722
1149
  return undefined;
723
1150
  const servers = getServersForFileWithConfig(filePath);
724
1151
  for (const server of servers) {
725
- const root = await server.root(filePath);
1152
+ const root = await this.resolveServerRoot(server, filePath);
726
1153
  if (!root)
727
1154
  continue;
728
1155
  const key = `${server.id}:${normalizeMapKey(root)}`;
@@ -744,7 +1171,7 @@ export class LSPService {
744
1171
  for (const server of getServersForFileWithConfig(filePath)) {
745
1172
  if (server.id !== serverId)
746
1173
  continue;
747
- const root = await server.root(filePath);
1174
+ const root = await this.resolveServerRoot(server, filePath);
748
1175
  if (!root)
749
1176
  continue;
750
1177
  const key = `${server.id}:${normalizeMapKey(root)}`;
@@ -763,10 +1190,10 @@ export class LSPService {
763
1190
  if (this.checkDestroyed())
764
1191
  return undefined;
765
1192
  }
766
- const root = await server.root(filePath);
1193
+ const root = await this.resolveServerRoot(server, filePath);
767
1194
  if (!root || this.checkDestroyed())
768
1195
  return undefined;
769
- const allowInstall = this.shouldAllowInstall(filePath, root);
1196
+ const allowInstall = this.shouldAllowInstall(server.id);
770
1197
  const normalizedRoot = normalizeMapKey(root);
771
1198
  const key = `${server.id}:${normalizedRoot}`;
772
1199
  const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
@@ -803,6 +1230,9 @@ export class LSPService {
803
1230
  const existing = this.state.clients.get(key);
804
1231
  if (existing) {
805
1232
  if (existing.isAlive()) {
1233
+ this.unavailableLogged.delete(key);
1234
+ this.clientLastUsedAt.set(key, Date.now());
1235
+ this.scheduleTypeScriptIdleEviction(key);
806
1236
  if (!this.warmStartLogged.has(key)) {
807
1237
  logSessionStart(`lsp warm-start ${server.id}: reused root=${root} file=${filePath}`);
808
1238
  this.warmStartLogged.add(key);
@@ -811,6 +1241,28 @@ export class LSPService {
811
1241
  }
812
1242
  // Dead client — was previously alive, now needs respawn
813
1243
  const spawnedAt = this.state.clientSpawnedAt.get(key);
1244
+ // #1127: capture BEFORE calling existing.shutdown() below — both
1245
+ // wasShutdownIntentional() and getExitedAt() read state that
1246
+ // existing.shutdown() itself mutates as a side effect of our own
1247
+ // cleanup (shutdownRequested flips true, and the process exit it
1248
+ // triggers would stamp exitedAt at CLEANUP time rather than at the
1249
+ // real death), so both must be read before that call.
1250
+ const wasIntentional = existing.wasShutdownIntentional();
1251
+ const exitedAt = existing.getExitedAt();
1252
+ // #1127: lifetime MUST be measured from the client's own recorded
1253
+ // death (exitedAt), not from "now" (this detection). Detection is
1254
+ // lazy — the next file attach — and #1127's real-world pattern is
1255
+ // attach-triggered respawns minutes to HOURS apart: a server that
1256
+ // died 5s after spawning but wasn't detected until an hour later
1257
+ // must still read as an early, breaker-worthy exit. Fall back to the
1258
+ // detection-time delta only when exitedAt is somehow unset (the
1259
+ // client's own exit handlers always set it before isAlive() can go
1260
+ // false, so this is a defensive fallback, not the expected path).
1261
+ const uptimeMs = exitedAt != null && spawnedAt != null
1262
+ ? exitedAt - spawnedAt
1263
+ : spawnedAt != null
1264
+ ? Date.now() - spawnedAt
1265
+ : null;
814
1266
  logLatency({
815
1267
  type: "phase",
816
1268
  phase: "lsp_server_respawn",
@@ -819,7 +1271,8 @@ export class LSPService {
819
1271
  metadata: {
820
1272
  serverId: server.id,
821
1273
  root,
822
- uptimeMs: spawnedAt != null ? Date.now() - spawnedAt : null,
1274
+ uptimeMs,
1275
+ intentional: wasIntentional,
823
1276
  },
824
1277
  });
825
1278
  try {
@@ -830,7 +1283,127 @@ export class LSPService {
830
1283
  }
831
1284
  this.state.clients.delete(key);
832
1285
  this.state.clientSpawnedAt.delete(key);
1286
+ this.clientLastUsedAt.delete(key);
1287
+ this.clearTypeScriptIdleTimer(key);
833
1288
  this.state.broken.delete(key);
1289
+ // #1127: count EARLY, non-intentional runtime exits toward the circuit
1290
+ // breaker. Spawn/initialize itself succeeded here (this client made it
1291
+ // into state.clients), so `failureCounts` — the spawn/init breaker —
1292
+ // never saw this failure and was cleared to 0 on that earlier success.
1293
+ // Without this, a server that spawns fine and then dies shortly after
1294
+ // serving (opengrep's post-init "Unhandled message" crash, #1122/#1127)
1295
+ // respawns forever: cooldown/permanent-disable never engage.
1296
+ //
1297
+ // Deliberate teardowns (session reset, #743 notify-backpressure
1298
+ // eviction, a generation handoff) call `shutdown()` themselves and set
1299
+ // `shutdownRequested` before the process exits — those must never count,
1300
+ // or a legitimate restart would wrongly march the server toward
1301
+ // permanent disablement.
1302
+ if (wasIntentional) {
1303
+ // Not evidence of a bad server — leave any existing runtime-exit
1304
+ // streak alone (a deliberate restart is not a recovery signal
1305
+ // either; only clear the streak once the server proves itself by
1306
+ // staying up past the threshold, handled in the `else` below). The
1307
+ // windowed-rate trip below is likewise gated behind this
1308
+ // `!wasIntentional` guard, so user-initiated restarts, config/
1309
+ // workspace reloads, session changes, #743 eviction and generation
1310
+ // handoffs (all shutdown()-driven) never feed it.
1311
+ }
1312
+ else {
1313
+ // #1142: windowed-rate trip — the SECOND, independent breaker
1314
+ // condition, composed with the fast path below. Record this
1315
+ // non-intentional death and, if RUNTIME_EXIT_WINDOW_TRIP_COUNT
1316
+ // deaths fell within the rolling window, give up NOW regardless of
1317
+ // each death's individual lifetime. This catches the slow loop
1318
+ // (dies just past the 60s threshold every spawn) that the
1319
+ // consecutive-early-exit fast path structurally cannot see.
1320
+ if (this.recordRuntimeExitWindow(key, exitedAt, uptimeMs)) {
1321
+ const rate = `${RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths within ${Math.round(RUNTIME_EXIT_WINDOW_MS / 60_000)}m`;
1322
+ // Set a cooldown so THIS detection also stops respawning (the
1323
+ // broken check below at the end of this method re-reads it),
1324
+ // symmetric with the fast path which cools down on every count.
1325
+ // `max` is defensive, not load-bearing under the actual call
1326
+ // order: `state.broken` for this key is `delete`d at the top of
1327
+ // this method (see the "detect dead client" branch above), so
1328
+ // this `.get(key) ?? 0` always reads 0 here — it's the fast
1329
+ // path below that runs AFTER this block and can raise the
1330
+ // cooldown for the same death. Harmless either way: the
1331
+ // non-optional branch latches `permanentlyBroken` (making the
1332
+ // cooldown moot), and the optional branch here is currently
1333
+ // unreachable (`OPTIONAL_LSP_SERVER_IDS` is empty).
1334
+ this.state.broken.set(key, Math.max(this.state.broken.get(key) ?? 0, Date.now() +
1335
+ (isOptionalServer
1336
+ ? OPTIONAL_LSP_RETRY_COOLDOWN_MS
1337
+ : BROKEN_MAX_COOLDOWN_MS)));
1338
+ if (isOptionalServer) {
1339
+ // Optional servers never latch permanently (mirrors the fast
1340
+ // path): cool down + disable for a retry interval, and reset
1341
+ // the window so the trip must be re-earned afterward.
1342
+ this.optionalDisabled.add(key);
1343
+ this.runtimeExitWindow.delete(key);
1344
+ logSessionStart(`lsp respawn ${server.id}: optional server cooled down (windowed-rate trip: ${rate})`);
1345
+ }
1346
+ else {
1347
+ this.permanentlyBroken.add(key);
1348
+ this.recordBreaker(key, `windowed runtime-exit trip: ${rate}`);
1349
+ logSessionStart(`lsp respawn ${server.id}: permanently disabled (windowed-rate trip: ${rate}, uptimeMs=${uptimeMs})`);
1350
+ }
1351
+ }
1352
+ // #1127/#1139 fast path — CONSECUTIVE exits UNDER the 60s
1353
+ // threshold. UNCHANGED: its own counter, its own trip point. A
1354
+ // single death may feed BOTH accumulators (the window above AND
1355
+ // runtimeExitCounts here), but neither alters the other's threshold,
1356
+ // so the fast path's trip point is exactly what it was.
1357
+ if (uptimeMs != null &&
1358
+ uptimeMs < RUNTIME_EXIT_UPTIME_THRESHOLD_MS) {
1359
+ const rCount = (this.runtimeExitCounts.get(key) ?? 0) + 1;
1360
+ this.runtimeExitCounts.set(key, rCount);
1361
+ const rCooldown = Math.min(BROKEN_BASE_COOLDOWN_MS * 2 ** (rCount - 1), BROKEN_MAX_COOLDOWN_MS);
1362
+ this.state.broken.set(key, Date.now() + rCooldown);
1363
+ if (!isOptionalServer && rCount >= BROKEN_PERMANENT_AFTER) {
1364
+ this.permanentlyBroken.add(key);
1365
+ this.recordBreaker(key, `permanently disabled after ${rCount} early post-init exits`);
1366
+ logSessionStart(`lsp respawn ${server.id}: permanently disabled after ${rCount} early post-init exits (uptimeMs=${uptimeMs})`);
1367
+ }
1368
+ else {
1369
+ logSessionStart(`lsp respawn ${server.id}: early post-init exit ${rCount}/${BROKEN_PERMANENT_AFTER} (uptimeMs=${uptimeMs}), cooldown=${rCooldown}ms`);
1370
+ }
1371
+ }
1372
+ else {
1373
+ // Survived past the threshold (or uptime unknown) before dying —
1374
+ // this was a functioning server, not a HOT crash loop. Reset the
1375
+ // consecutive-early streak so an occasional post-longrun crash
1376
+ // doesn't creep it toward permanent disablement. The windowed
1377
+ // counter above is deliberately NOT reset here: a ~65-90s death
1378
+ // lands in this branch yet IS the churn #1142 targets — the
1379
+ // window ages those out by time instead.
1380
+ this.runtimeExitCounts.delete(key);
1381
+ // Outer-map hygiene (#1183): runtimeExitWindow itself is only
1382
+ // ever `delete`d on the optional-trip branch above (currently
1383
+ // unreachable — OPTIONAL_LSP_SERVER_IDS is empty), so a key
1384
+ // that crashed once then recovered would otherwise retain a
1385
+ // stale timestamp array forever, even once every entry in it
1386
+ // has aged out of the window. Drop it here once nothing in it
1387
+ // is still in-window — purely map hygiene: a later death
1388
+ // rebuilds the array from scratch via recordRuntimeExitWindow,
1389
+ // so this cannot change trip behavior. Anchor "now" on this
1390
+ // death's own `exitedAt` (falling back to real time only if
1391
+ // unset), NOT `Date.now()` directly — this death's timestamp
1392
+ // is what `recordRuntimeExitWindow` itself anchors pruning on
1393
+ // (see its `windowRef` comment), so a death that WAS just
1394
+ // recorded is guaranteed to still be in-window here and this
1395
+ // can only ever fire for the "declined to record" cases
1396
+ // (over the sleep-gap ceiling, or a missing `exitedAt`) where
1397
+ // nothing new was added and the array is genuinely stale.
1398
+ const windowDeaths = this.runtimeExitWindow.get(key);
1399
+ if (windowDeaths) {
1400
+ const windowNow = exitedAt ?? Date.now();
1401
+ if (!windowDeaths.some((t) => t >= windowNow - RUNTIME_EXIT_WINDOW_MS)) {
1402
+ this.runtimeExitWindow.delete(key);
1403
+ }
1404
+ }
1405
+ }
1406
+ }
834
1407
  }
835
1408
  const brokenUntil = this.state.broken.get(key);
836
1409
  if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
@@ -851,31 +1424,53 @@ export class LSPService {
851
1424
  if (isOptionalServer)
852
1425
  this.optionalDisabled.delete(key);
853
1426
  }
854
- const inFlight = this.state.inFlight.get(key);
855
- if (inFlight) {
856
- return inFlight;
1427
+ let spawnPromise = this.state.inFlight.get(key);
1428
+ if (!spawnPromise) {
1429
+ const started = await this.withClientSpawnGate(async () => {
1430
+ const raced = this.state.inFlight.get(key);
1431
+ if (raced)
1432
+ return { promise: raced };
1433
+ // `server.root()` and dead-client cleanup above are async. A reset during
1434
+ // either gap must not let this retired generation start a late spawn.
1435
+ if (this.checkDestroyed())
1436
+ return undefined;
1437
+ if (!(await this.makeCapacityForClient(key)))
1438
+ return undefined;
1439
+ const promise = this.spawnClient(server, root, key, filePath, allowInstall);
1440
+ this.state.inFlight.set(key, promise);
1441
+ return { promise };
1442
+ });
1443
+ if (!started)
1444
+ return undefined;
1445
+ spawnPromise = started.promise;
857
1446
  }
858
- // `server.root()` and a dead client's shutdown above are both async. A
859
- // reset during either gap may have completed without seeing this request in
860
- // state.inFlight; never let that retired generation start a late spawn.
861
- if (this.checkDestroyed())
862
- return undefined;
863
- const spawnPromise = this.spawnClient(server, root, key, filePath, allowInstall);
864
- this.state.inFlight.set(key, spawnPromise);
865
1447
  try {
866
1448
  return await spawnPromise;
867
1449
  }
868
1450
  finally {
869
- this.state.inFlight.delete(key);
1451
+ if (this.state.inFlight.get(key) === spawnPromise) {
1452
+ this.state.inFlight.delete(key);
1453
+ }
870
1454
  }
871
1455
  }
872
- shouldAllowInstall(_filePath, _root) {
1456
+ shouldAllowInstall(serverId) {
1457
+ if (!assertInstallAllowed(`lsp install: ${serverId}`))
1458
+ return false;
873
1459
  return process.env.PI_LENS_DISABLE_LSP_INSTALL !== "1";
874
1460
  }
875
1461
  /**
876
1462
  * Internal: spawn a client for a server/root combination
877
1463
  */
878
1464
  async spawnClient(server, root, key, filePath, allowInstall) {
1465
+ // #1334 S5: honor the host project-trust decision before executing any
1466
+ // project-resolved binary. Only an explicit host "not trusted" blocks —
1467
+ // a host with no trust surface (`"unknown"`) spawns exactly as before.
1468
+ // Deliberately NOT marked broken: trust is a policy outcome, not a server
1469
+ // failure, and the user may grant trust later in the same session.
1470
+ if (!isLspSpawnAllowedByTrust()) {
1471
+ logSessionStart(`lsp spawn ${server.id}: refused — ${projectTrustDenialReason()}`);
1472
+ return undefined;
1473
+ }
879
1474
  const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
880
1475
  const startedAt = Date.now();
881
1476
  logSessionStart(`lsp spawn ${server.id}: start root=${root} install=${allowInstall ? "enabled" : "disabled"} file=${filePath}`);
@@ -914,6 +1509,7 @@ export class LSPService {
914
1509
  this.state.broken.set(key, Date.now() + uCooldown);
915
1510
  if (uCount >= BROKEN_PERMANENT_AFTER) {
916
1511
  this.permanentlyBroken.add(key);
1512
+ this.recordBreaker(key, `permanently disabled after ${uCount} unavailable spawns`);
917
1513
  logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${uCount} failures`);
918
1514
  }
919
1515
  return undefined;
@@ -943,7 +1539,10 @@ export class LSPService {
943
1539
  diagnosticProviderKind: "unavailable",
944
1540
  };
945
1541
  this.state.clients.set(key, client);
1542
+ this.unavailableLogged.delete(key);
946
1543
  this.state.clientSpawnedAt.set(key, Date.now());
1544
+ this.clientLastUsedAt.set(key, Date.now());
1545
+ this.scheduleTypeScriptIdleEviction(key);
947
1546
  this.failureCounts.delete(key);
948
1547
  if (isOptionalServer) {
949
1548
  this.optionalDisabled.delete(key);
@@ -973,6 +1572,7 @@ export class LSPService {
973
1572
  this.state.broken.set(key, Date.now() + eCooldown);
974
1573
  if (!isOptionalServer && eCount >= BROKEN_PERMANENT_AFTER) {
975
1574
  this.permanentlyBroken.add(key);
1575
+ this.recordBreaker(key, `permanently disabled after ${eCount} spawn/initialize failures`);
976
1576
  logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${eCount} failures`);
977
1577
  }
978
1578
  if (isOptionalServer) {
@@ -987,11 +1587,10 @@ export class LSPService {
987
1587
  async openFile(filePath, content, options) {
988
1588
  if (this.checkDestroyed())
989
1589
  return;
990
- const spawned = await this.getClientForFile(filePath, undefined, options?.spawnBudgetMs);
991
- if (!spawned)
992
- return;
993
- const languageId = getLanguageId(filePath) ?? "plaintext";
994
- await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
1590
+ await this.withClientForFileUse(filePath, undefined, options?.spawnBudgetMs, async (spawned) => {
1591
+ const languageId = getLanguageId(filePath) ?? "plaintext";
1592
+ await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
1593
+ });
995
1594
  }
996
1595
  /**
997
1596
  * Update file content (sends textDocument/didChange)
@@ -999,10 +1598,7 @@ export class LSPService {
999
1598
  async updateFile(filePath, content) {
1000
1599
  if (this.checkDestroyed())
1001
1600
  return;
1002
- const spawned = await this.getClientForFile(filePath);
1003
- if (!spawned)
1004
- return;
1005
- await spawned.client.notify.change(filePath, content);
1601
+ await this.withClientForFileUse(filePath, undefined, undefined, (spawned) => spawned.client.notify.change(filePath, content));
1006
1602
  }
1007
1603
  /**
1008
1604
  * Touch a file like OpenCode's LSP flow: ensure document is open/synced,
@@ -1064,533 +1660,822 @@ export class LSPService {
1064
1660
  });
1065
1661
  return;
1066
1662
  }
1067
- if (this.shouldSkipTouch(filePath, content, clientScope, diagnosticsMode !== "none")) {
1068
- logLatency({
1069
- type: "phase",
1070
- phase: "lsp_touch_file",
1071
- filePath: normalizedPath,
1072
- durationMs: Date.now() - startedAt,
1073
- metadata: {
1074
- serverCountReady: spawned.length,
1075
- clientScope,
1076
- diagnosticsMode,
1077
- source,
1078
- failureKind: "success",
1079
- skipped: true,
1080
- reason: "debounced_unchanged_content",
1081
- },
1082
- });
1083
- return [];
1663
+ const leaseKeys = await this.acquireClientLeases(spawned, filePath);
1664
+ if (!leaseKeys) {
1665
+ // An idle eviction won between selection and lease admission. Resolve the
1666
+ // now-current client set and replay; no notification targets the retiree.
1667
+ return this.touchFile(filePath, content, options);
1084
1668
  }
1085
- const languageId = getLanguageId(filePath) ?? "plaintext";
1086
- const silent = options.silent ?? false;
1087
- // When the same content was already pushed to the LSP within the touch
1088
- // debounce window, skip the notify — pushing again clears the LSP's
1089
- // diagnostic cache (via notify.open) and forces it to restart work it
1090
- // already did. This is what makes the post-write touch + dispatch-lsp-
1091
- // runner touch sequence expensive on slow TS projects.
1092
- const notifySkipped = this.shouldSkipNotify(filePath, content, clientScope);
1093
- const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
1094
- // #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
1095
- // write gets its OWN notifyWriteBudgetMs budget rather than one shared
1096
- // deadline over a single Promise.all — otherwise one backpressured server
1097
- // (stalled stdin) times out the write for the ENTIRE file, flipping every
1098
- // co-touched healthy server to inconclusive and zeroing its diagnostics.
1099
- // Bounded so a backpressured write can't hang the caller; on timeout we
1100
- // proceed — the diagnostics wait below is separately bounded and simply
1101
- // returns no fresh diagnostics for the server(s) that stalled.
1102
- //
1103
- // Holds the serverId of every server whose write did NOT land in time.
1104
- // The file-level `notifyWriteTimedOut` (logged below) means "at least one
1105
- // server timed out"; this list carries the per-server detail the
1106
- // demonstratedReady gate reads so a healthy sibling stays eligible.
1107
- const notifyWriteTimedOutServerIds = [];
1108
- if (!notifySkipped) {
1109
- const budget = notifyWriteBudgetMs();
1110
- await Promise.all(spawned.map(async (entry) => {
1111
- // Same identity as the broken/demonstratedReady maps.
1112
- const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
1113
- let wrote;
1114
- let rejected = false;
1115
- try {
1116
- wrote = await withDeadline(entry.client.notify
1117
- .open(filePath, content, languageId, undefined, silent)
1118
- .then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
1119
- }
1120
- catch {
1121
- // The write itself rejected (not backpressure): the content did
1122
- // not land, so this server is inconclusive for the touch, but a
1123
- // rejection is not a stdin-backpressure signal and must not count
1124
- // toward the backpressure demotion streak.
1125
- rejected = true;
1126
- }
1127
- if (wrote === true) {
1128
- // A clean write clears any accrued backpressure streak (#743).
1129
- if (clientKey)
1130
- this.notifyWriteBackpressureStreak.delete(clientKey);
1131
- }
1132
- else {
1133
- notifyWriteTimedOutServerIds.push(entry.info.id);
1134
- if (!rejected) {
1135
- this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
1136
- }
1137
- }
1138
- return true;
1139
- }));
1140
- if (notifyWriteTimedOutServerIds.length > 0) {
1669
+ try {
1670
+ const spawnedServerIds = spawned.map((entry) => entry.info.id);
1671
+ if (this.shouldSkipTouch(filePath, content, clientScope, diagnosticsMode !== "none", spawnedServerIds)) {
1141
1672
  logLatency({
1142
1673
  type: "phase",
1143
- phase: "lsp_notify_timeout",
1674
+ phase: "lsp_touch_file",
1144
1675
  filePath: normalizedPath,
1145
1676
  durationMs: Date.now() - startedAt,
1146
1677
  metadata: {
1147
- source,
1678
+ serverCountReady: spawned.length,
1148
1679
  clientScope,
1149
- serverCount: spawned.length,
1150
- timedOutServerIds: notifyWriteTimedOutServerIds,
1680
+ diagnosticsMode,
1681
+ source,
1682
+ failureKind: "success",
1683
+ skipped: true,
1684
+ reason: "debounced_unchanged_content",
1151
1685
  },
1152
1686
  });
1687
+ // #1179: a debounced skip collected nothing — resolve the wrapper's
1688
+ // `.diags` as empty with neither flag (exactly the pre-wrapper `[]`).
1689
+ return { diags: [] };
1153
1690
  }
1154
- }
1155
- // File-level flag: at least one server's write timed out (kept for the
1156
- // conservative touch-wide `inconclusive` merge semantics — see below).
1157
- const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
1158
- let diagnosticsTimedOut = false;
1159
- // R8 (#714): server ids of aux-role servers whose push wait was cut off by
1160
- // the aux grace window. Undefined when no aux was cut off (primary-only
1161
- // paths never set this). Logged in lsp_touch_file metadata.
1162
- let auxCutOffServerIds;
1163
- // #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
1164
- // full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
1165
- // holds the sync commands' answer when the racing confirm won the wait
1166
- // (undefined = the race didn't produce an answer; the end-of-wait
1167
- // fallback below may still fill it in on a timed-out empty result).
1168
- let tsserverSyncEligible = false;
1169
- let tsserverSyncConfirmed;
1170
- if (diagnosticsMode !== "none") {
1171
- // Resolution: env wins so users can tune the cap without rebuilding.
1172
- // Otherwise, on the single-server hot path (primary scope), use that
1173
- // server's own strategy budget (server-strategies.ts) so a fast server
1174
- // (TypeScript ~1s) isn't held to a flat multi-second wait while a slow
1175
- // one (rust-analyzer 3s) gets the time it needs — bounded by any caller
1176
- // ceiling that exists to protect the per-edit pipeline budget (#203).
1177
- // #573: clientScope "all" (lsp_diagnostics, lens_diagnostics_full) now
1178
- // gets the same per-server treatment as "with-auxiliary" — each spawned
1179
- // server (primary + any auxiliaries) is bounded by ITS OWN strategy
1180
- // budget instead of one flat number shared by every server. This was
1181
- // never a deliberate "all means wait for the group ceiling" semantic:
1182
- // #203 introduced perServerTimeout only for the single-server primary
1183
- // path and left "full"/"all" on the pre-existing flat resolution
1184
- // ("full/cascade path unchanged"); #242 later added "with-auxiliary"
1185
- // without revisiting "all". The one property "all" genuinely needs —
1186
- // the touch's overall detection deadline is the SLOWEST spawned
1187
- // server's budget, not the fastest — is unaffected: `timeoutMs` below
1188
- // is always `Math.max(...spawned.map(timeoutFor))` regardless of which
1189
- // timeoutFor is selected, so a slow auxiliary still gets to run to its
1190
- // own budget before the touch is logged as timed out. What changes is
1191
- // only that a fast server's *individual* `waitForDiagnostics` call
1192
- // (further below) now resolves/times out against its own budget
1193
- // instead of blocking to the flat multi-server number.
1194
- const envWait = readEnvDiagnosticsWaitMs();
1195
- const callerCap = options.maxDiagnosticsWaitMs ?? options.maxClientWaitMs;
1196
- const modeFloor = diagnosticsMode === "full" ? 3000 : 1200;
1197
- // #645: resolve each spawned server's "is this the first same-sweep
1198
- // touch for it" verdict EXACTLY ONCE up front, before `perServerTimeout`
1199
- // is defined. `SweepIndexGate.consumeFirstTouch` is side-effecting
1200
- // (it marks the server seen), and `perServerTimeout` below is invoked
1201
- // twice per server in this call (once to compute the overall
1202
- // `timeoutMs` deadline, again inside the wait `Promise.all`) — calling
1203
- // the gate directly from inside `perServerTimeout` would consume the
1204
- // "first touch" slot on the first of those two calls and read as
1205
- // already-warm on the second, silently shortchanging the very touch
1206
- // that was supposed to get the full budget.
1207
- const sweepFirstTouch = new Map();
1208
- if (options.sweepIndexGate) {
1209
- for (const entry of spawned) {
1210
- const strategy = getStrategy(entry.client.serverId);
1211
- if (strategy.workspaceIndexing) {
1212
- sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
1213
- }
1214
- }
1215
- }
1216
- // #832: workspace-indexing servers that are classified as silent on
1217
- // clean do not benefit from the generic cold-indexing floor. Their
1218
- // configured strategy already gives the first sweep touch a bounded
1219
- // workspace-index budget (marksman: 1500ms), while the capability
1220
- // classification proves that a clean push has no affirmative signal to
1221
- // wait for. Keep this restricted to the workspace-indexing strategy:
1222
- // TypeScript is also a silent-on-clean push server, but its cold project
1223
- // load still needs the longer 20s floor.
1691
+ const languageId = getLanguageId(filePath) ?? "plaintext";
1692
+ const silent = options.silent ?? false;
1693
+ // When the same content was already pushed to the LSP within the touch
1694
+ // debounce window, skip the notify — pushing again clears the LSP's
1695
+ // diagnostic cache (via notify.open) and forces it to restart work it
1696
+ // already did. This is what makes the post-write touch + dispatch-lsp-
1697
+ // runner touch sequence expensive on slow TS projects.
1224
1698
  //
1225
- // Build this from the live spawned client's capabilities rather than
1226
- // server id alone. Missing/throwing capability data fails closed, so a
1227
- // new or ambiguous server keeps the existing generous warm-up budget.
1228
- const silentCleanWarmupServers = new Set();
1229
- if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
1230
- for (const entry of spawned) {
1231
- const strategy = getStrategy(entry.client.serverId);
1232
- if (strategy.workspaceIndexing !== true)
1233
- continue;
1699
+ // #743: resolved PER SERVER. A server whose sibling's write stalled last
1700
+ // touch still holds its own debounce entry, so it is skipped here while the
1701
+ // stalled server (which has no entry) gets re-pushed. `notifySkipped` stays
1702
+ // as the file-level "every server was skipped" summary for the logs and the
1703
+ // no-new-version baseline below.
1704
+ const notifySkippedServerIds = new Set(spawnedServerIds.filter((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId)));
1705
+ const notifySkipped = spawned.length > 0 && notifySkippedServerIds.size === spawned.length;
1706
+ const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
1707
+ // #1458: read a late auxiliary publication BEFORE the ordinary resync
1708
+ // clears its client cache. Carry it only when the publication's exact
1709
+ // sent-content fingerprint matches this touch's content. A changed edit,
1710
+ // version-less publication, or malformed binding fails closed and is not
1711
+ // replayed. The fresh notify still runs below, so scanners continue toward
1712
+ // a publication for this touch while the prior late result reaches the read.
1713
+ const touchContentHash = this.hashContent(content);
1714
+ const carriedAuxiliary = options.collectDiagnostics
1715
+ ? spawned.flatMap((entry) => {
1716
+ if (entry.info.role !== "auxiliary")
1717
+ return [];
1718
+ const binding = entry.client.getDiagnosticBinding?.(filePath);
1719
+ if (binding?.contentHash !== touchContentHash)
1720
+ return [];
1721
+ const diags = entry.client.getDiagnostics(filePath);
1722
+ return diags.length > 0 ? [{ diags, binding }] : [];
1723
+ })
1724
+ : [];
1725
+ // #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
1726
+ // write gets its OWN notifyWriteBudgetMs budget rather than one shared
1727
+ // deadline over a single Promise.all — otherwise one backpressured server
1728
+ // (stalled stdin) times out the write for the ENTIRE file, flipping every
1729
+ // co-touched healthy server to inconclusive and zeroing its diagnostics.
1730
+ // Bounded so a backpressured write can't hang the caller; on timeout we
1731
+ // proceed — the diagnostics wait below is separately bounded and simply
1732
+ // returns no fresh diagnostics for the server(s) that stalled.
1733
+ //
1734
+ // Holds the serverId of every server whose write did NOT land in time.
1735
+ // The file-level `notifyWriteTimedOut` (logged below) means "at least one
1736
+ // server timed out"; this list carries the per-server detail the
1737
+ // demonstratedReady gate reads so a healthy sibling stays eligible.
1738
+ const notifyWriteTimedOutServerIds = [];
1739
+ if (!notifySkipped) {
1740
+ const budget = notifyWriteBudgetMs();
1741
+ await Promise.all(spawned.map(async (entry) => {
1742
+ // #743: this server already has this content from a recent touch
1743
+ // that landed. Pushing again would clear its diagnostic cache for
1744
+ // nothing — leave its debounce entry (and its original timestamp)
1745
+ // alone so the window still expires naturally.
1746
+ if (notifySkippedServerIds.has(entry.info.id))
1747
+ return;
1748
+ // Same identity as the broken/demonstratedReady maps.
1749
+ const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
1750
+ let wrote;
1751
+ let rejected = false;
1234
1752
  try {
1235
- const snapshot = {
1236
- serverId: entry.client.serverId,
1237
- root: entry.client.root,
1238
- operationSupport: entry.client.getOperationSupport(),
1239
- workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
1240
- advertisedCommands: entry.client.getAdvertisedCommands(),
1241
- rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
1242
- launchVariant: entry.client.getLaunchVariant?.(),
1243
- };
1244
- if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
1245
- "tier3-silent") {
1246
- silentCleanWarmupServers.add(entry.client.serverId);
1247
- }
1753
+ wrote = await withDeadline(entry.client.notify
1754
+ .open(filePath, content, languageId, undefined, silent)
1755
+ .then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
1248
1756
  }
1249
1757
  catch {
1250
- // Fail closed: capability uncertainty must retain the cold floor.
1758
+ // The write itself rejected (not backpressure): the content did
1759
+ // not land, so this server is inconclusive for the touch, but a
1760
+ // rejection is not a stdin-backpressure signal and must not count
1761
+ // toward the backpressure demotion streak.
1762
+ rejected = true;
1251
1763
  }
1764
+ if (wrote === true) {
1765
+ // A clean write clears any accrued backpressure streak (#743).
1766
+ if (clientKey)
1767
+ this.notifyWriteBackpressureStreak.delete(clientKey);
1768
+ // #1253: record the debounce entry for THIS server only, and only
1769
+ // because its own write landed. A server whose write timed out or
1770
+ // rejected falls through without an entry, so the next touch
1771
+ // re-pushes it instead of laundering the failure into a later
1772
+ // touch that looks fully delivered (which the silent-clean gates
1773
+ // would then read as a confirmed clean).
1774
+ this.markTouched(filePath, content, clientScope, entry.info.id);
1775
+ }
1776
+ else {
1777
+ notifyWriteTimedOutServerIds.push(entry.info.id);
1778
+ if (!rejected) {
1779
+ this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
1780
+ }
1781
+ }
1782
+ }));
1783
+ if (notifyWriteTimedOutServerIds.length > 0) {
1784
+ logLatency({
1785
+ type: "phase",
1786
+ phase: "lsp_notify_timeout",
1787
+ filePath: normalizedPath,
1788
+ durationMs: Date.now() - startedAt,
1789
+ metadata: {
1790
+ source,
1791
+ clientScope,
1792
+ serverCount: spawned.length,
1793
+ timedOutServerIds: notifyWriteTimedOutServerIds,
1794
+ },
1795
+ });
1252
1796
  }
1253
1797
  }
1254
- // Each server gets its OWN deadline, bounded by the caller cap as a
1255
- // CEILING (never a floor) — so a clean push-silent primary (typescript
1256
- // ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
1257
- // slow aux (opengrep) can't override the per-edit cap. Resolves as soon
1258
- // as a server publishes; this is just its individual deadline. (#242)
1259
- const perServerTimeout = (serverId) => {
1260
- const strategy = getStrategy(serverId);
1261
- let strategyWait = strategy.aggregateWaitMs;
1262
- // #645: a `workspaceIndexing` server (marksman) only needs the
1263
- // full budget for the FIRST same-sweep touch to it — every
1264
- // subsequent touch in this sweep uses the much shorter warm-wait
1265
- // instead, since the one-time index build only needs to finish
1266
- // once. `sweepFirstTouch` only has entries when a sweep gate was
1267
- // passed in AND the strategy is marked, so a per-edit touch
1268
- // (no gate) or an unmarked server is completely unaffected.
1269
- const isFirstTouch = sweepFirstTouch.get(serverId);
1270
- if (isFirstTouch === false && strategy.workspaceIndexing) {
1271
- strategyWait =
1272
- strategy.workspaceIndexingWarmWaitMs ??
1273
- Math.min(300, strategyWait);
1798
+ // File-level flag: at least one server's write timed out (kept for the
1799
+ // conservative touch-wide `inconclusive` merge semantics — see below).
1800
+ const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
1801
+ let diagnosticsTimedOut = false;
1802
+ // R8 (#714): server ids of aux-role servers whose push wait was cut off by
1803
+ // the aux grace window. Undefined when no aux was cut off (primary-only
1804
+ // paths never set this). Logged in lsp_touch_file metadata.
1805
+ let auxCutOffServerIds;
1806
+ // #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
1807
+ // full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
1808
+ // holds the sync commands' answer when the racing confirm won the wait
1809
+ // (undefined = the race didn't produce an answer; the end-of-wait
1810
+ // fallback below may still fill it in on a timed-out empty result).
1811
+ let tsserverSyncEligible = false;
1812
+ let tsserverSyncConfirmed;
1813
+ if (diagnosticsMode !== "none") {
1814
+ // Resolution: env wins so users can tune the cap without rebuilding.
1815
+ // Otherwise, on the single-server hot path (primary scope), use that
1816
+ // server's own strategy budget (wait-policy/strategies.ts) so a fast server
1817
+ // (TypeScript ~1s) isn't held to a flat multi-second wait while a slow
1818
+ // one (rust-analyzer 3s) gets the time it needs — bounded by any caller
1819
+ // ceiling that exists to protect the per-edit pipeline budget (#203).
1820
+ // #573: clientScope "all" (lsp_diagnostics, lens_diagnostics_full) now
1821
+ // gets the same per-server treatment as "with-auxiliary" — each spawned
1822
+ // server (primary + any auxiliaries) is bounded by ITS OWN strategy
1823
+ // budget instead of one flat number shared by every server. This was
1824
+ // never a deliberate "all means wait for the group ceiling" semantic:
1825
+ // #203 introduced perServerTimeout only for the single-server primary
1826
+ // path and left "full"/"all" on the pre-existing flat resolution
1827
+ // ("full/cascade path unchanged"); #242 later added "with-auxiliary"
1828
+ // without revisiting "all". The one property "all" genuinely needs —
1829
+ // the touch's overall detection deadline is the SLOWEST spawned
1830
+ // server's budget, not the fastest — is unaffected: `timeoutMs` below
1831
+ // is always `Math.max(...spawned.map(timeoutFor))` regardless of which
1832
+ // timeoutFor is selected, so a slow auxiliary still gets to run to its
1833
+ // own budget before the touch is logged as timed out. What changes is
1834
+ // only that a fast server's *individual* `waitForDiagnostics` call
1835
+ // (further below) now resolves/times out against its own budget
1836
+ // instead of blocking to the flat multi-server number.
1837
+ const envWait = readEnvDiagnosticsWaitMs();
1838
+ const callerCap = options.maxDiagnosticsWaitMs ?? options.maxClientWaitMs;
1839
+ const modeFloor = diagnosticsMode === "full" ? 3000 : 1200;
1840
+ // #645: resolve each spawned server's "is this the first same-sweep
1841
+ // touch for it" verdict EXACTLY ONCE up front, before `perServerTimeout`
1842
+ // is defined. `SweepIndexGate.consumeFirstTouch` is side-effecting
1843
+ // (it marks the server seen), and `perServerTimeout` below is invoked
1844
+ // twice per server in this call (once to compute the overall
1845
+ // `timeoutMs` deadline, again inside the wait `Promise.all`) — calling
1846
+ // the gate directly from inside `perServerTimeout` would consume the
1847
+ // "first touch" slot on the first of those two calls and read as
1848
+ // already-warm on the second, silently shortchanging the very touch
1849
+ // that was supposed to get the full budget.
1850
+ const sweepFirstTouch = new Map();
1851
+ if (options.sweepIndexGate) {
1852
+ for (const entry of spawned) {
1853
+ const strategy = getStrategy(entry.client.serverId, entry.client.getLaunchVariant?.());
1854
+ if (strategy.workspaceIndexing) {
1855
+ sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
1856
+ }
1857
+ }
1274
1858
  }
1275
- if (callerCap !== undefined) {
1276
- // #669: `ensureWarmForSweep`'s cold-server warm-up wants its cap
1277
- // to act as a FLOOR (give it at least this much, possibly more
1278
- // if the strategy already wants more) rather than the normal
1279
- // ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
1280
- if (options.warmupOverride) {
1281
- // #832: a workspace-indexing server already classified as
1282
- // silent-on-clean uses its strategy's bounded wait on the first
1283
- // attempt; the generic cold floor is for servers whose cold work
1284
- // can eventually produce a push answer (notably TypeScript).
1285
- if (silentCleanWarmupServers.has(serverId)) {
1286
- return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1859
+ // #832: workspace-indexing servers that are classified as silent on
1860
+ // clean do not benefit from the generic cold-indexing floor. Their
1861
+ // configured strategy already gives the first sweep touch a bounded
1862
+ // workspace-index budget (marksman: 1500ms), while the capability
1863
+ // classification proves that a clean push has no affirmative signal to
1864
+ // wait for. Keep this restricted to the workspace-indexing strategy:
1865
+ // TypeScript is also a silent-on-clean push server, but its cold project
1866
+ // load still needs the longer 20s floor.
1867
+ //
1868
+ // Build this from the live spawned client's capabilities rather than
1869
+ // server id alone. Missing/throwing capability data fails closed, so a
1870
+ // new or ambiguous server keeps the existing generous warm-up budget.
1871
+ const silentCleanWarmupServers = new Set();
1872
+ if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
1873
+ for (const entry of spawned) {
1874
+ const strategy = getStrategy(entry.client.serverId, entry.client.getLaunchVariant?.());
1875
+ if (strategy.workspaceIndexing !== true)
1876
+ continue;
1877
+ try {
1878
+ const snapshot = {
1879
+ serverId: entry.client.serverId,
1880
+ root: entry.client.root,
1881
+ operationSupport: entry.client.getOperationSupport(),
1882
+ workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
1883
+ advertisedCommands: entry.client.getAdvertisedCommands(),
1884
+ rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
1885
+ launchVariant: entry.client.getLaunchVariant?.(),
1886
+ };
1887
+ if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
1888
+ "tier3-silent") {
1889
+ silentCleanWarmupServers.add(entry.client.serverId);
1890
+ }
1287
1891
  }
1288
- // #799: only the FIRST warm-up attempt for a cold server gets the
1289
- // floor — see the `warmupAttempt` doc on `LSPTouchFileOptions`.
1290
- if ((options.warmupAttempt ?? 1) > 1) {
1291
- return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1892
+ catch {
1893
+ // Fail closed: capability uncertainty must retain the cold floor.
1292
1894
  }
1293
- return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
1294
1895
  }
1295
- return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1296
1896
  }
1297
- return strategyWait > 0 ? strategyWait : modeFloor;
1298
- };
1299
- let timeoutFor;
1300
- if (envWait !== undefined) {
1301
- // Env override is a single flat cap so users can tune without rebuilding.
1302
- timeoutFor = () => envWait;
1303
- }
1304
- else if ((!useAllClients && spawned.length === 1) ||
1305
- clientScope === "with-auxiliary" ||
1306
- clientScope === "all") {
1307
- timeoutFor = perServerTimeout;
1308
- }
1309
- else {
1310
- // Fail-safe for any future clientScope this branch hasn't been
1311
- // taught about yet — keep the old flat resolution rather than
1312
- // silently mis-budgeting an unrecognized scope.
1313
- timeoutFor = () => callerCap ?? modeFloor;
1314
- }
1315
- // Detection deadline = the slowest individual server's budget.
1316
- const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
1317
- // #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
1318
- // starts. Cheap synchronous gates first (notify succeeded, collecting,
1319
- // primary scope, `serverId === "typescript"` — the sync commands this
1320
- // races are tsserver-specific protocol extensions, not a generic
1321
- // push-only capability, so #799 giving other servers (marksman) the
1322
- // SAME `silentOnClean` marker must not route them into a sync attempt
1323
- // that can never succeed for them), then the live capability-snapshot
1324
- // tier classification (`classifyCascadeWaitTier`, which also excludes
1325
- // native-ts7 via `launchVariant`). Every other server fails this
1326
- // synchronous gate and pays ZERO extra work — not even the snapshot
1327
- // read; a non-typescript `silentOnClean` server instead gets the
1328
- // generic (non-racing) clean-confirm fallback further below.
1329
- if (!notifyWriteTimedOut &&
1330
- options.collectDiagnostics === true &&
1331
- clientScope === "primary" &&
1332
- spawned.length === 1 &&
1333
- spawned[0].client.serverId === "typescript" &&
1334
- getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1335
- try {
1336
- const snapshots = await this.getCapabilitySnapshots(filePath);
1337
- tsserverSyncEligible =
1338
- classifyCascadeWaitTier(this, filePath, snapshots) ===
1339
- "tier3-silent";
1897
+ // Each server gets its OWN deadline, bounded by the caller cap as a
1898
+ // CEILING (never a floor) — so a clean push-silent primary (typescript
1899
+ // ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
1900
+ // slow aux (opengrep) can't override the per-edit cap. Resolves as soon
1901
+ // as a server publishes; this is just its individual deadline. (#242)
1902
+ const perServerTimeout = (serverId) => {
1903
+ const launchVariant = spawned.find((entry) => entry.client.serverId === serverId)?.client.getLaunchVariant?.();
1904
+ const strategy = getStrategy(serverId, launchVariant);
1905
+ let strategyWait = strategy.aggregateWaitMs;
1906
+ // #645: a `workspaceIndexing` server (marksman) only needs the
1907
+ // full budget for the FIRST same-sweep touch to it — every
1908
+ // subsequent touch in this sweep uses the much shorter warm-wait
1909
+ // instead, since the one-time index build only needs to finish
1910
+ // once. `sweepFirstTouch` only has entries when a sweep gate was
1911
+ // passed in AND the strategy is marked, so a per-edit touch
1912
+ // (no gate) or an unmarked server is completely unaffected.
1913
+ const isFirstTouch = sweepFirstTouch.get(serverId);
1914
+ if (isFirstTouch === false && strategy.workspaceIndexing) {
1915
+ strategyWait =
1916
+ strategy.workspaceIndexingWarmWaitMs ??
1917
+ Math.min(300, strategyWait);
1918
+ }
1919
+ if (callerCap !== undefined) {
1920
+ // #669: `ensureWarmForSweep`'s cold-server warm-up wants its cap
1921
+ // to act as a FLOOR (give it at least this much, possibly more
1922
+ // if the strategy already wants more) rather than the normal
1923
+ // ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
1924
+ if (options.warmupOverride) {
1925
+ // #832: a workspace-indexing server already classified as
1926
+ // silent-on-clean uses its strategy's bounded wait on the first
1927
+ // attempt; the generic cold floor is for servers whose cold work
1928
+ // can eventually produce a push answer (notably TypeScript).
1929
+ if (silentCleanWarmupServers.has(serverId)) {
1930
+ return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1931
+ }
1932
+ // #799: only the FIRST warm-up attempt for a cold server gets the
1933
+ // floor — see the `warmupAttempt` doc on `LSPTouchFileOptions`.
1934
+ if ((options.warmupAttempt ?? 1) > 1) {
1935
+ return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1936
+ }
1937
+ return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
1938
+ }
1939
+ return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1940
+ }
1941
+ return strategyWait > 0 ? strategyWait : modeFloor;
1942
+ };
1943
+ let timeoutFor;
1944
+ if (envWait !== undefined) {
1945
+ // Env override is a single flat cap so users can tune without rebuilding.
1946
+ timeoutFor = () => envWait;
1340
1947
  }
1341
- catch {
1342
- // Fail-safe: ineligible — today's full wait, no sync attempt.
1948
+ else if ((!useAllClients && spawned.length === 1) ||
1949
+ clientScope === "with-auxiliary" ||
1950
+ clientScope === "all") {
1951
+ timeoutFor = perServerTimeout;
1343
1952
  }
1344
- }
1345
- const waitStartedAt = Date.now();
1346
- // R8 (#714): on the with-auxiliary path, apply a bounded aux grace so a
1347
- // slow auxiliary no longer holds the push wait to its own deadline.
1348
- // Primary waits resolve on their own per-server budget; once ALL primaries
1349
- // have settled the auxiliaries get at most auxGraceMs before we proceed.
1350
- // Primary-only and "all"/"primary" scopes are completely unaffected —
1351
- // they fall through to the original Promise.all path below.
1352
- //
1353
- // "Primary" here = a server whose LSPServerInfo.role is not "auxiliary".
1354
- // In the with-auxiliary spawn list, `getClientForFile` returns the
1355
- // language-primary entry first and `getAuxiliaryClientsForFile` appends
1356
- // the rest — but we use info.role rather than position so the logic is
1357
- // correct even if ordering shifts in the future.
1358
- //
1359
- // The #707 tsserver sync race operates exclusively on single-server
1360
- // primary-scope touches (guarded by `clientScope === "primary" &&
1361
- // spawned.length === 1`), so there is NO interaction with this path.
1362
- const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
1363
- spawned.some((e) => e.info.role === "auxiliary");
1364
- // Per-server wait promises (each already bounded by its own
1365
- // perServerTimeout — unchanged from before R8).
1366
- let pressureSnapshots = [];
1367
- if (shouldPreferPullOnlyDiagnostics()) {
1368
- try {
1369
- pressureSnapshots = await this.getCapabilitySnapshots(filePath);
1953
+ else {
1954
+ // Fail-safe for any future clientScope this branch hasn't been
1955
+ // taught about yet — keep the old flat resolution rather than
1956
+ // silently mis-budgeting an unrecognized scope.
1957
+ timeoutFor = () => callerCap ?? modeFloor;
1370
1958
  }
1371
- catch {
1372
- // Fail-open: missing capability state keeps today's push fallback.
1959
+ // Detection deadline = the slowest individual server's budget.
1960
+ const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
1961
+ // #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
1962
+ // starts. Cheap synchronous gates first (notify succeeded, collecting,
1963
+ // primary scope, `serverId === "typescript"` — the sync commands this
1964
+ // races are tsserver-specific protocol extensions, not a generic
1965
+ // push-only capability, so #799 giving other servers (marksman) the
1966
+ // SAME `silentOnClean` marker must not route them into a sync attempt
1967
+ // that can never succeed for them), then the live capability-snapshot
1968
+ // tier classification (`classifyCascadeWaitTier`, which also excludes
1969
+ // native-ts7 via `launchVariant`). Every other server fails this
1970
+ // synchronous gate and pays ZERO extra work — not even the snapshot
1971
+ // read; a non-typescript `silentOnClean` server instead gets the
1972
+ // generic (non-racing) clean-confirm fallback further below.
1973
+ if (!notifyWriteTimedOut &&
1974
+ options.collectDiagnostics === true &&
1975
+ clientScope === "primary" &&
1976
+ spawned.length === 1 &&
1977
+ spawned[0].client.serverId === "typescript" &&
1978
+ getStrategy(spawned[0].client.serverId, spawned[0].client.getLaunchVariant?.()).silentOnClean === true) {
1979
+ try {
1980
+ const snapshots = await this.getCapabilitySnapshots(filePath);
1981
+ tsserverSyncEligible =
1982
+ classifyCascadeWaitTier(this, filePath, snapshots) ===
1983
+ "tier3-silent";
1984
+ }
1985
+ catch {
1986
+ // Fail-safe: ineligible — today's full wait, no sync attempt.
1987
+ }
1373
1988
  }
1374
- }
1375
- const perServerWaits = spawned.map((entry) => {
1376
- const serverTimeout = timeoutFor(entry.client.serverId);
1377
- const baseline = diagnosticBaselines.get(entry.client);
1378
- const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
1379
- const wait = !notifySkipped && Number.isFinite(baseline)
1380
- ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
1381
- minVersion: baseline,
1382
- ...(pullOnly && { pullOnly: true }),
1383
- })
1384
- : pullOnly
1989
+ const waitStartedAt = Date.now();
1990
+ // R8 (#714): on the with-auxiliary path, apply a bounded aux grace so a
1991
+ // slow auxiliary no longer holds the push wait to its own deadline.
1992
+ // Primary waits resolve on their own per-server budget; once ALL primaries
1993
+ // have settled the auxiliaries get at most auxGraceMs before we proceed.
1994
+ // Primary-only and "all"/"primary" scopes are completely unaffected —
1995
+ // they fall through to the original Promise.all path below.
1996
+ //
1997
+ // "Primary" here = a server whose LSPServerInfo.role is not "auxiliary".
1998
+ // In the with-auxiliary spawn list, `getClientForFile` returns the
1999
+ // language-primary entry first and `getAuxiliaryClientsForFile` appends
2000
+ // the rest — but we use info.role rather than position so the logic is
2001
+ // correct even if ordering shifts in the future.
2002
+ //
2003
+ // The #707 tsserver sync race operates exclusively on single-server
2004
+ // primary-scope touches (guarded by `clientScope === "primary" &&
2005
+ // spawned.length === 1`), so there is NO interaction with this path.
2006
+ // #1458 S4: also gated on `collectDiagnostics` — a non-collecting
2007
+ // with-auxiliary touch has nothing to carry the aux wait's result
2008
+ // INTO (its diagnostics are discarded either way), so paying up to
2009
+ // `auxCeilingMs` of extra latency for it buys nothing. Both current
2010
+ // callers (`getDiagnostics`'s with-auxiliary path and the cascade's
2011
+ // collecting touch) already pass `collectDiagnostics: true`, so this
2012
+ // is latent-today defense, not a behavior change — but a future
2013
+ // non-collecting with-auxiliary caller must not silently inherit the
2014
+ // full aux-grace cost for diagnostics it's about to throw away.
2015
+ const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
2016
+ options.collectDiagnostics === true &&
2017
+ spawned.some((e) => e.info.role === "auxiliary");
2018
+ // Per-server wait promises (each already bounded by its own
2019
+ // perServerTimeout — unchanged from before R8).
2020
+ let pressureSnapshots = [];
2021
+ if (shouldPreferPullOnlyDiagnostics()) {
2022
+ try {
2023
+ pressureSnapshots = await this.getCapabilitySnapshots(filePath);
2024
+ }
2025
+ catch {
2026
+ // Fail-open: missing capability state keeps today's push fallback.
2027
+ }
2028
+ }
2029
+ const perServerWaits = spawned.map((entry) => {
2030
+ const serverTimeout = timeoutFor(entry.client.serverId);
2031
+ const baseline = diagnosticBaselines.get(entry.client);
2032
+ const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
2033
+ // #743: per-server — a server we DID push to still gets the
2034
+ // version-baseline wait even when a sibling was debounced away.
2035
+ const wait = !notifySkippedServerIds.has(entry.info.id) && Number.isFinite(baseline)
1385
2036
  ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
1386
- pullOnly: true,
2037
+ minVersion: baseline,
2038
+ ...(pullOnly && { pullOnly: true }),
1387
2039
  })
1388
- : entry.client.waitForDiagnostics(filePath, serverTimeout);
1389
- return wait.catch(() => undefined);
1390
- });
1391
- // The push wait — same per-server budget composition as before #707;
1392
- // only the awaiting changed (assigned so it can be raced below).
1393
- let pushWaitSettled = false;
1394
- const pushWait = hasTouchAuxiliaries
1395
- ? (() => {
1396
- // Primary waits: all non-auxiliary servers.
1397
- const primaryWaits = perServerWaits.filter((_, i) => spawned[i].info.role !== "auxiliary");
1398
- // Aux waits: auxiliary servers (advisory).
1399
- const auxWaits = perServerWaits
1400
- .map((p, i) => spawned[i].info.role === "auxiliary"
1401
- ? { promise: p, serverId: spawned[i].info.id }
1402
- : null)
1403
- .filter((x) => x !== null);
1404
- const auxGraceMs = readEnvAuxGraceMs() ?? 500;
1405
- // After all primaries settle, give auxiliaries at most auxGraceMs.
1406
- // Late aux results are dropped from the wait (advisory only — they
1407
- // land in the client cache and surface on the next edit); aux servers
1408
- // that did answer within the grace are included automatically since
1409
- // their waitForDiagnostics already resolved. The cut-off server ids
1410
- // are logged in the latency metadata (lsp_touch_file phase, field
1411
- // `auxCutOffServerIds`).
1412
- return Promise.all(primaryWaits).then(async () => {
1413
- if (auxWaits.length === 0)
1414
- return;
1415
- const auxTimeout = new Promise((resolve) => {
1416
- const t = setTimeout(() => resolve("timeout"), auxGraceMs);
1417
- if (typeof t === "object" && "unref" in t)
1418
- t.unref?.();
1419
- });
1420
- const auxAll = Promise.all(auxWaits.map((a) => a.promise)).then(() => "done");
1421
- const outcome = await Promise.race([auxAll, auxTimeout]);
1422
- if (outcome === "timeout") {
1423
- // Record which auxiliaries did NOT finish in time.
1424
- const unfinished = [];
1425
- for (const a of auxWaits) {
1426
- let done = false;
1427
- // Check synchronously if already resolved by racing against
1428
- // a resolved promise.
1429
- await Promise.race([
1430
- a.promise.then(() => {
1431
- done = true;
1432
- }),
1433
- Promise.resolve(),
1434
- ]);
1435
- if (!done)
1436
- unfinished.push(a.serverId);
2040
+ : pullOnly
2041
+ ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
2042
+ pullOnly: true,
2043
+ })
2044
+ : entry.client.waitForDiagnostics(filePath, serverTimeout);
2045
+ return wait.catch(() => undefined);
2046
+ });
2047
+ // The push wait — same per-server budget composition as before #707;
2048
+ // only the awaiting changed (assigned so it can be raced below).
2049
+ let pushWaitSettled = false;
2050
+ const pushWait = hasTouchAuxiliaries
2051
+ ? (() => {
2052
+ // Primary waits: all non-auxiliary servers.
2053
+ const primaryWaits = perServerWaits.filter((_, i) => spawned[i].info.role !== "auxiliary");
2054
+ // Aux waits: auxiliary servers (advisory). `client` and the
2055
+ // pre-notify `diagnosticsVersion` baseline travel alongside the
2056
+ // promise so the outcome can be decided from EVIDENCE after the
2057
+ // race, not from how the raced promise settled (#1458 S1 — see
2058
+ // below).
2059
+ const auxWaits = perServerWaits
2060
+ .map((p, i) => spawned[i].info.role === "auxiliary"
2061
+ ? {
2062
+ promise: p,
2063
+ serverId: spawned[i].info.id,
2064
+ client: spawned[i].client,
2065
+ baseline: diagnosticBaselines.get(spawned[i].client),
1437
2066
  }
2067
+ : null)
2068
+ .filter((x) => x !== null);
2069
+ const auxCeilingMs = readEnvAuxGraceMs() ?? 2000;
2070
+ // After all primaries settle, give each auxiliary the smaller of
2071
+ // its declared wait budget and the global auxiliary ceiling. The
2072
+ // 2000ms default admits measured ~1.3s warm scanner runs without
2073
+ // making every edit pay opengrep's 3500ms cold-start allowance.
2074
+ // Late aux results are dropped from this wait. A later unchanged-
2075
+ // content read may carry a SHA-256-bound cache publication before its
2076
+ // resync clears the cache; changed or unknown content never replays.
2077
+ // Aux servers that answer within the grace are included automatically since
2078
+ // their waitForDiagnostics already resolved. The cut-off server ids
2079
+ // are logged in the latency metadata (lsp_touch_file phase, field
2080
+ // `auxCutOffServerIds`).
2081
+ return Promise.all(primaryWaits).then(async () => {
2082
+ if (auxWaits.length === 0)
2083
+ return;
2084
+ const auxWaitStartedAt = Date.now();
2085
+ const outcomes = await Promise.all(auxWaits.map(async (aux) => {
2086
+ const budgetMs = Math.min(timeoutFor(aux.serverId), auxCeilingMs);
2087
+ let timer;
2088
+ const timeout = new Promise((resolve) => {
2089
+ timer = setTimeout(() => resolve(false), budgetMs);
2090
+ if (typeof timer === "object" && "unref" in timer) {
2091
+ timer.unref?.();
2092
+ }
2093
+ });
2094
+ const raced = await Promise.race([
2095
+ aux.promise.then(() => true),
2096
+ timeout,
2097
+ ]);
2098
+ if (timer)
2099
+ clearTimeout(timer);
2100
+ // #1458 S1: `waitForDiagnostics` RESOLVES on its own timeout
2101
+ // (client.ts) — it never rejects, and a silent scanner that
2102
+ // published nothing looks identical, promise-wise, to one
2103
+ // that answered. `raced === true` only means "the promise
2104
+ // settled before our timer fired"; it is not proof anything
2105
+ // was published. Decide the outcome from evidence instead:
2106
+ // did this aux's `diagnosticsVersion` advance past the
2107
+ // pre-notify baseline captured before the wait started?
2108
+ // - raced === false → "cut_off" (our timer won;
2109
+ // the aux's own wait never got to answer for itself).
2110
+ // - raced === true, no evidence → "silent" (the aux's own
2111
+ // wait gave up within its budget with nothing to report —
2112
+ // NOT the same as having answered).
2113
+ // - raced === true, evidence → "answered" (a fresh
2114
+ // publication actually landed for this touch).
2115
+ const publishedEvidence = raced &&
2116
+ Number.isFinite(aux.baseline) &&
2117
+ aux.client.diagnosticsVersion > aux.baseline;
2118
+ const outcome = !raced
2119
+ ? "cut_off"
2120
+ : publishedEvidence
2121
+ ? "answered"
2122
+ : "silent";
2123
+ return {
2124
+ serverId: aux.serverId,
2125
+ outcome,
2126
+ budgetMs,
2127
+ elapsedMs: Date.now() - auxWaitStartedAt,
2128
+ // #1458 S3: elapsed measured from BEFORE the primary wait
2129
+ // (waitStartedAt), not just from auxWaitStartedAt — this is
2130
+ // what lets a latency row validate the ~1.3s warm-scanner
2131
+ // figure the 2000ms ceiling was set from; `elapsedMs` alone
2132
+ // only covers the POST-primary aux phase.
2133
+ elapsedSinceNotifyMs: Date.now() - waitStartedAt,
2134
+ };
2135
+ }));
2136
+ const unfinished = outcomes
2137
+ .filter((outcome) => outcome.outcome === "cut_off")
2138
+ .map((outcome) => outcome.serverId);
1438
2139
  if (unfinished.length > 0)
1439
2140
  auxCutOffServerIds = unfinished;
2141
+ logLatency({
2142
+ type: "phase",
2143
+ phase: "lsp_aux_wait_outcome",
2144
+ filePath: normalizedPath,
2145
+ durationMs: Date.now() - auxWaitStartedAt,
2146
+ metadata: { clientScope, outcomes },
2147
+ });
2148
+ });
2149
+ })()
2150
+ : Promise.all(perServerWaits).then(() => { });
2151
+ pushWait.then(() => {
2152
+ pushWaitSettled = true;
2153
+ });
2154
+ if (tsserverSyncEligible) {
2155
+ // #707 racing variant: rather than burning the full push-wait budget
2156
+ // on a silent-on-clean server (which by definition never answers on a
2157
+ // clean file), race the push wait against a grace-delayed sync
2158
+ // confirm. The grace (default 300ms, PI_LENS_TSSERVER_SYNC_GRACE_MS)
2159
+ // gives a genuinely dirty file's push a head start: if diagnostics
2160
+ // arrive before the grace elapses, the sync request never goes out —
2161
+ // zero new latency or requests on the push-answers path.
2162
+ //
2163
+ // Race semantics:
2164
+ // - sync answers first → that's the confirmed result (clean OR
2165
+ // dirty — the sync commands return the file's real syntactic +
2166
+ // semantic state, so a dirty-file win is still correct and its
2167
+ // findings are surfaced, never discarded).
2168
+ // - push settles first → push wins; a still-in-flight sync outcome
2169
+ // is discarded (the racer checks `pushWaitSettled` after the
2170
+ // call returns and drops its own result).
2171
+ // - sync unavailable/fails → the racer parks on a never-resolving
2172
+ // promise so the race is decided by the push wait's own budget,
2173
+ // exactly today's behavior (the end-of-wait fallback below still
2174
+ // gets its shot on a timed-out empty result).
2175
+ // The racer never rejects (every failure path is caught), so the
2176
+ // losing promise can never surface as an unhandled rejection.
2177
+ const graceMs = readTsserverSyncGraceMs();
2178
+ const primaryClient = spawned[0].client;
2179
+ // Resolves with the sync commands' diagnostics when the confirm
2180
+ // succeeds; parks on a never-resolving promise on EVERY other path
2181
+ // (push already answered, sync unavailable/failed, push won while
2182
+ // in flight) so the race is then decided by the push wait's own
2183
+ // budget — exactly today's behavior.
2184
+ const syncRacer = (async () => {
2185
+ await new Promise((resolve) => {
2186
+ const timer = setTimeout(resolve, graceMs);
2187
+ timer.unref?.();
2188
+ });
2189
+ // Push already answered (settled, or published diagnostics that
2190
+ // its wait is about to settle on) — nothing to confirm, no sync
2191
+ // request goes out.
2192
+ if (pushWaitSettled ||
2193
+ primaryClient.getDiagnostics(filePath).length > 0) {
2194
+ return new Promise(() => { });
1440
2195
  }
2196
+ try {
2197
+ const result = await attemptTsserverSyncDiagnostics(filePath, this);
2198
+ if (result === undefined || pushWaitSettled) {
2199
+ // Sync unavailable/failed, or push won while the sync call
2200
+ // was in flight — drop the sync outcome and let the push
2201
+ // wait decide the race.
2202
+ return new Promise(() => { });
2203
+ }
2204
+ return result;
2205
+ }
2206
+ catch {
2207
+ return new Promise(() => { });
2208
+ }
2209
+ })();
2210
+ const raceOutcome = await Promise.race([
2211
+ pushWait.then(() => undefined),
2212
+ syncRacer,
2213
+ ]);
2214
+ if (raceOutcome !== undefined) {
2215
+ tsserverSyncConfirmed = raceOutcome;
2216
+ }
2217
+ }
2218
+ else {
2219
+ await pushWait;
2220
+ }
2221
+ const waitedMs = Date.now() - waitStartedAt;
2222
+ if (tsserverSyncConfirmed !== undefined) {
2223
+ // #707: the racing sync confirm won — a definitive answer well under
2224
+ // the push-wait budget. Not a timeout, not inconclusive.
2225
+ logLatency({
2226
+ type: "phase",
2227
+ phase: "lsp_tsserver_sync_confirm",
2228
+ filePath: normalizedPath,
2229
+ durationMs: waitedMs,
2230
+ metadata: {
2231
+ source,
2232
+ serverId: spawned[0]?.client.serverId,
2233
+ clientScope,
2234
+ diagnosticsMode,
2235
+ mode: "race",
2236
+ confirmedDiagnosticCount: tsserverSyncConfirmed.length,
2237
+ budgetMs: timeoutMs,
2238
+ savedVsBudgetMs: Math.max(0, timeoutMs - waitedMs),
2239
+ },
1441
2240
  });
1442
- })()
1443
- : Promise.all(perServerWaits).then(() => { });
1444
- pushWait.then(() => {
1445
- pushWaitSettled = true;
1446
- });
1447
- if (tsserverSyncEligible) {
1448
- // #707 racing variant: rather than burning the full push-wait budget
1449
- // on a silent-on-clean server (which by definition never answers on a
1450
- // clean file), race the push wait against a grace-delayed sync
1451
- // confirm. The grace (default 300ms, PI_LENS_TSSERVER_SYNC_GRACE_MS)
1452
- // gives a genuinely dirty file's push a head start: if diagnostics
1453
- // arrive before the grace elapses, the sync request never goes out —
1454
- // zero new latency or requests on the push-answers path.
1455
- //
1456
- // Race semantics:
1457
- // - sync answers first → that's the confirmed result (clean OR
1458
- // dirty — the sync commands return the file's real syntactic +
1459
- // semantic state, so a dirty-file win is still correct and its
1460
- // findings are surfaced, never discarded).
1461
- // - push settles first → push wins; a still-in-flight sync outcome
1462
- // is discarded (the racer checks `pushWaitSettled` after the
1463
- // call returns and drops its own result).
1464
- // - sync unavailable/fails → the racer parks on a never-resolving
1465
- // promise so the race is decided by the push wait's own budget,
1466
- // exactly today's behavior (the end-of-wait fallback below still
1467
- // gets its shot on a timed-out empty result).
1468
- // The racer never rejects (every failure path is caught), so the
1469
- // losing promise can never surface as an unhandled rejection.
1470
- const graceMs = readTsserverSyncGraceMs();
1471
- const primaryClient = spawned[0].client;
1472
- // Resolves with the sync commands' diagnostics when the confirm
1473
- // succeeds; parks on a never-resolving promise on EVERY other path
1474
- // (push already answered, sync unavailable/failed, push won while
1475
- // in flight) so the race is then decided by the push wait's own
1476
- // budget — exactly today's behavior.
1477
- const syncRacer = (async () => {
1478
- await new Promise((resolve) => {
1479
- const timer = setTimeout(resolve, graceMs);
1480
- timer.unref?.();
1481
- });
1482
- // Push already answered (settled, or published diagnostics that
1483
- // its wait is about to settle on) — nothing to confirm, no sync
1484
- // request goes out.
1485
- if (pushWaitSettled ||
1486
- primaryClient.getDiagnostics(filePath).length > 0) {
1487
- return new Promise(() => { });
2241
+ }
2242
+ else if (waitedMs + 20 >= timeoutMs) {
2243
+ // Within ~20 ms of the configured budget we treat it as a timeout;
2244
+ // the LSP didn't beat the cap. Diagnostics that arrive late still
2245
+ // land in the client's cache and surface on the next edit.
2246
+ diagnosticsTimedOut = true;
2247
+ for (const entry of spawned) {
2248
+ incrementDegradationCount({
2249
+ kind: "lsp-diagnostics-timeout",
2250
+ // `info.id` is the authoritative server identity carried by
2251
+ // every spawned entry. The client test doubles (and some
2252
+ // lightweight clients) need not expose a serverId property;
2253
+ // ledger recording must never abort the touch or alter #570's
2254
+ // timeout-preserves-last-known-diagnostics semantics.
2255
+ subject: entry.info.id,
2256
+ reason: "diagnostics wait timed out",
2257
+ });
1488
2258
  }
2259
+ logLatency({
2260
+ type: "phase",
2261
+ phase: "lsp_diagnostics_timeout",
2262
+ filePath: normalizedPath,
2263
+ durationMs: waitedMs,
2264
+ metadata: {
2265
+ source,
2266
+ // #1444: WHICH server(s) burned the budget — without this the
2267
+ // ~221/day timeout rows can't be attributed to a server at all.
2268
+ // `info.id` (not `client.serverId`) for the same reason the
2269
+ // degradation ledger above uses it: test doubles and lightweight
2270
+ // clients need not expose `serverId`.
2271
+ serverIds: spawned.map((e) => e.info.id),
2272
+ clientScope,
2273
+ diagnosticsMode,
2274
+ timeoutMs,
2275
+ },
2276
+ });
2277
+ }
2278
+ // #814: capability-aware AGGREGATE wait — generalize #799's
2279
+ // single-server (`clientScope === "primary" && spawned.length === 1`)
2280
+ // silent-clean confirm to multi-server `clientScope: "all"` touches
2281
+ // (`lens_diagnostics` mode=full per-file sweep, `lsp_diagnostics`
2282
+ // `serverScope: "all"`). #799's gate never fires here (it's scoped to
2283
+ // the primary hot path), so a scope-"all" touch where every OTHER
2284
+ // spawned server already answered but one push-only `silentOnClean`
2285
+ // server (marksman on a clean markdown file) never publishes still
2286
+ // reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut` even
2287
+ // though the "silence" is exactly what that server's own known
2288
+ // clean-behavior predicts — not an unresolved question.
2289
+ //
2290
+ // A spawned server counts as "still outstanding" when nothing landed
2291
+ // in its per-file diagnostics cache for THIS touch — `getAllDiagnostics`
2292
+ // is keyed by file and `clearDiagnosticsForPath` (`client.ts`) deletes
2293
+ // that file's entry as part of the didOpen/didChange this touch just
2294
+ // sent, so a present entry can only be a FRESH answer (found or a real
2295
+ // confirmed-empty push/pull), never a stale one bleeding through from
2296
+ // an earlier touch. This is the same "did anything publish for this
2297
+ // file since we asked" signal `cascade-tier.ts`'s Tier-3 reconcile
2298
+ // already trusts (#240 doctrine) — reused here, not reinvented.
2299
+ //
2300
+ // The touch stays inconclusive unless EVERY still-outstanding server
2301
+ // is classified `tier3-silent` (push-only + `silentOnClean`, the same
2302
+ // `classifyServerWaitTier` rule the single-server gate below and the
2303
+ // cascade lane use) — one ordinary push-only straggler (still
2304
+ // genuinely analyzing) or a pull-capable server that never answered
2305
+ // keeps the touch cautious, matching #799's "err toward caution"
2306
+ // posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
2307
+ // plus the per-server re-check below are the same "the notify write
2308
+ // must have actually landed" conservatism #799 established — a
2309
+ // server's silence is only evidence of "clean" when we know it saw
2310
+ // the new content.
2311
+ if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
1489
2312
  try {
1490
- const result = await attemptTsserverSyncDiagnostics(filePath, this);
1491
- if (result === undefined || pushWaitSettled) {
1492
- // Sync unavailable/failed, or push won while the sync call
1493
- // was in flight — drop the sync outcome and let the push
1494
- // wait decide the race.
1495
- return new Promise(() => { });
2313
+ const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
2314
+ !entry.client.getAllDiagnostics().has(normalizedPath));
2315
+ if (outstanding.length > 0) {
2316
+ const snapshots = await this.getCapabilitySnapshots(filePath);
2317
+ const allSilent = outstanding.every((entry) => classifyServerWaitTier(entry.client.serverId, snapshots.find((s) => s.serverId === entry.client.serverId)) === "tier3-silent");
2318
+ if (allSilent) {
2319
+ // #1277: the static tier3-silent classification alone can't
2320
+ // tell a genuinely clean server from one that accepted the
2321
+ // notify write and then wedged — both look identical to
2322
+ // `classifyServerWaitTier`, which only reads the capability
2323
+ // snapshot, never the server's actual current responsiveness.
2324
+ // Require every still-outstanding server to answer a cheap
2325
+ // bounded round-trip before trusting the silence as clean;
2326
+ // any server that doesn't respond in time keeps the touch
2327
+ // inconclusive rather than confirming a possibly-dead server
2328
+ // clean.
2329
+ const liveness = await Promise.all(outstanding.map((entry) => (entry.client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false)));
2330
+ if (liveness.every(Boolean)) {
2331
+ diagnosticsTimedOut = false;
2332
+ logLatency({
2333
+ type: "phase",
2334
+ phase: "lsp_silent_clean_confirm",
2335
+ filePath: normalizedPath,
2336
+ durationMs: Date.now() - startedAt,
2337
+ metadata: {
2338
+ source,
2339
+ clientScope,
2340
+ diagnosticsMode,
2341
+ aggregate: true,
2342
+ serverIds: outstanding.map((entry) => entry.client.serverId),
2343
+ },
2344
+ });
2345
+ }
2346
+ }
1496
2347
  }
1497
- return result;
1498
2348
  }
1499
2349
  catch {
1500
- return new Promise(() => { });
2350
+ // Fail-safe: leave `diagnosticsTimedOut` as-is — today's
2351
+ // inconclusive behavior, exactly like the single-server gate.
1501
2352
  }
1502
- })();
1503
- const raceOutcome = await Promise.race([
1504
- pushWait.then(() => undefined),
1505
- syncRacer,
1506
- ]);
1507
- if (raceOutcome !== undefined) {
1508
- tsserverSyncConfirmed = raceOutcome;
1509
2353
  }
1510
2354
  }
1511
- else {
1512
- await pushWait;
1513
- }
1514
- const waitedMs = Date.now() - waitStartedAt;
1515
- if (tsserverSyncConfirmed !== undefined) {
1516
- // #707: the racing sync confirm won — a definitive answer well under
1517
- // the push-wait budget. Not a timeout, not inconclusive.
1518
- logLatency({
1519
- type: "phase",
1520
- phase: "lsp_tsserver_sync_confirm",
1521
- filePath: normalizedPath,
1522
- durationMs: waitedMs,
1523
- metadata: {
1524
- source,
1525
- clientScope,
1526
- diagnosticsMode,
1527
- mode: "race",
1528
- confirmedDiagnosticCount: tsserverSyncConfirmed.length,
1529
- budgetMs: timeoutMs,
1530
- savedVsBudgetMs: Math.max(0, timeoutMs - waitedMs),
1531
- },
1532
- });
1533
- }
1534
- else if (waitedMs + 20 >= timeoutMs) {
1535
- // Within ~20 ms of the configured budget we treat it as a timeout;
1536
- // the LSP didn't beat the cap. Diagnostics that arrive late still
1537
- // land in the client's cache and surface on the next edit.
1538
- diagnosticsTimedOut = true;
1539
- logLatency({
1540
- type: "phase",
1541
- phase: "lsp_diagnostics_timeout",
1542
- filePath: normalizedPath,
1543
- durationMs: waitedMs,
1544
- metadata: {
1545
- source,
1546
- clientScope,
1547
- diagnosticsMode,
1548
- timeoutMs,
1549
- },
1550
- });
2355
+ // #707: when the racing sync confirm won the wait, its answer IS the
2356
+ // collected result — the file's real syntactic + semantic state straight
2357
+ // from tsserver (clean = [], dirty = real findings that a silentOnClean
2358
+ // server had computed but never published). Otherwise merge the push
2359
+ // diagnostics from the client cache as always.
2360
+ let collected = options.collectDiagnostics
2361
+ ? tsserverSyncConfirmed !== undefined
2362
+ ? mergeLspDiagnostics(tsserverSyncConfirmed)
2363
+ : mergeLspDiagnostics([
2364
+ ...spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)),
2365
+ ...carriedAuxiliary.flatMap((entry) => entry.diags),
2366
+ ])
2367
+ : undefined;
2368
+ // #1095 (P3-b): whether `collected` came from a tsserver sync confirm
2369
+ // (`tsserverSyncRequest`) rather than the publish cache. A sync-confirmed
2370
+ // result is authoritative for the CURRENT buffer but is NOT tied to the
2371
+ // publish-path content binding (`diagnosticBindings`, set on publish), so
2372
+ // composing that binding here could let a STALE publish fingerprint demote a
2373
+ // genuinely-fresh sync answer to `false`. The end-of-wait fallback below can
2374
+ // also set this. When true, the binding is surfaced as "unknown" (honest,
2375
+ // non-demoting) rather than the stale publish binding.
2376
+ let syncConfirmed = tsserverSyncConfirmed !== undefined;
2377
+ // #707 end-of-wait fallback: when the racing confirm did NOT decide the
2378
+ // wait (sync unavailable/failed mid-race, or push resolved as a bare
2379
+ // timeout) and the wait timed out with an empty result on an eligible
2380
+ // touch, give the sync clean-confirm one last shot before reporting
2381
+ // inconclusive. `tsserverSyncEligible` already encodes every gate (notify
2382
+ // succeeded, collecting, primary scope, tier3-silent classic typescript —
2383
+ // native-ts7 excluded by `classifyCascadeWaitTier`). If the sync call
2384
+ // answers (even with an empty body, which is a confirmed clean), we use
2385
+ // those diagnostics as the confirmed result and clear the
2386
+ // `diagnosticsTimedOut` flag so the touch is no longer treated as
2387
+ // inconclusive. Sync diagnostics on a dirty file are surfaced, not
2388
+ // discarded. If the sync call fails or is unavailable, we fall through to
2389
+ // today's behavior: `inconclusive` = true, `collected` unchanged. This
2390
+ // turns "unconfirmed after ~1000ms" into "confirmed at ~wait+sync-RTT"
2391
+ // even when the race path couldn't answer.
2392
+ if (diagnosticsTimedOut &&
2393
+ tsserverSyncEligible &&
2394
+ collected !== undefined &&
2395
+ collected.length === 0) {
2396
+ try {
2397
+ const syncResult = await attemptTsserverSyncDiagnostics(filePath, this);
2398
+ if (syncResult !== undefined) {
2399
+ // Sync answered — confirmed result (clean or with diagnostics).
2400
+ // Clear the timed-out flag so the touch is no longer inconclusive.
2401
+ diagnosticsTimedOut = false;
2402
+ syncConfirmed = true;
2403
+ collected = syncResult.length > 0
2404
+ ? mergeLspDiagnostics(syncResult)
2405
+ : [];
2406
+ logLatency({
2407
+ type: "phase",
2408
+ phase: "lsp_tsserver_sync_confirm",
2409
+ filePath: normalizedPath,
2410
+ durationMs: Date.now() - startedAt,
2411
+ metadata: {
2412
+ source,
2413
+ clientScope,
2414
+ diagnosticsMode,
2415
+ mode: "end-of-wait",
2416
+ confirmedDiagnosticCount: collected.length,
2417
+ },
2418
+ });
2419
+ }
2420
+ }
2421
+ catch {
2422
+ // Any failure here falls through to today's inconclusive behavior.
2423
+ }
1551
2424
  }
1552
- // #814: capability-aware AGGREGATE wait — generalize #799's
1553
- // single-server (`clientScope === "primary" && spawned.length === 1`)
1554
- // silent-clean confirm to multi-server `clientScope: "all"` touches
1555
- // (`lens_diagnostics` mode=full per-file sweep, `lsp_diagnostics`
1556
- // `serverScope: "all"`). #799's gate never fires here (it's scoped to
1557
- // the primary hot path), so a scope-"all" touch where every OTHER
1558
- // spawned server already answered but one push-only `silentOnClean`
1559
- // server (marksman on a clean markdown file) never publishes still
1560
- // reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut` even
1561
- // though the "silence" is exactly what that server's own known
1562
- // clean-behavior predicts — not an unresolved question.
1563
- //
1564
- // A spawned server counts as "still outstanding" when nothing landed
1565
- // in its per-file diagnostics cache for THIS touch — `getAllDiagnostics`
1566
- // is keyed by file and `clearDiagnosticsForPath` (`client.ts`) deletes
1567
- // that file's entry as part of the didOpen/didChange this touch just
1568
- // sent, so a present entry can only be a FRESH answer (found or a real
1569
- // confirmed-empty push/pull), never a stale one bleeding through from
1570
- // an earlier touch. This is the same "did anything publish for this
1571
- // file since we asked" signal `cascade-tier.ts`'s Tier-3 reconcile
1572
- // already trusts (#240 doctrine) — reused here, not reinvented.
2425
+ // #799: generalize the "silent-clean push-only" confirm beyond
2426
+ // typescript's active sync-command race above. That mechanism is
2427
+ // TS-specific (`attemptTsserverSyncDiagnostics` races an actual
2428
+ // `typescript.tsserverRequest` — no equivalent protocol exists for
2429
+ // e.g. marksman) and is now scoped to `serverId === "typescript"`
2430
+ // only (see the gate above), so it never fires for another
2431
+ // `silentOnClean` server. This is the generic fallback for those
2432
+ // servers: if the wait ran its full budget with a successful notify
2433
+ // write and nothing published, and the live capability snapshot
2434
+ // classifies this touch as tier3-silent (push-only + `silentOnClean`,
2435
+ // #458's `classifyCascadeWaitTier`), that is not "still working" —
2436
+ // `silentOnClean` means by definition this server publishes NOTHING
2437
+ // on a clean transition, so a timeout under those conditions IS the
2438
+ // confirmed-clean answer. `!tsserverSyncEligible` keeps this from
2439
+ // ever double-deciding typescript's own touches — when the sync race
2440
+ // was attempted and failed/was unavailable, typescript's existing
2441
+ // "falls through to inconclusive, unchanged" contract (#707) is
2442
+ // preserved exactly; typescript touches that never enter that gate
2443
+ // (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
2444
+ // warm-up call) are still eligible here as a genuine bonus fix. Scoped
2445
+ // to `clientScope === "primary"`/`spawned.length === 1` exactly like
2446
+ // the sync-eligible gate above (and like `ensureWarmForSweep`'s own
2447
+ // `clientScope: "primary"` warm-up touch) so a multi-server
2448
+ // with-auxiliary/all touch — where a partial timeout must stay
2449
+ // cautious per the doc below — is never affected.
1573
2450
  //
1574
- // The touch stays inconclusive unless EVERY still-outstanding server
1575
- // is classified `tier3-silent` (push-only + `silentOnClean`, the same
1576
- // `classifyServerWaitTier` rule the single-server gate below and the
1577
- // cascade lane use) — one ordinary push-only straggler (still
1578
- // genuinely analyzing) or a pull-capable server that never answered
1579
- // keeps the touch cautious, matching #799's "err toward caution"
1580
- // posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
1581
- // plus the per-server re-check below are the same "the notify write
1582
- // must have actually landed" conservatism #799 established — a
1583
- // server's silence is only evidence of "clean" when we know it saw
1584
- // the new content.
1585
- if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
2451
+ // #814: this is now a SPECIAL CASE of the more general per-server gate
2452
+ // above (the `clientScope === "all"` block right before the diagnostics-
2453
+ // wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
2454
+ // server is tier3-silent" collapses to exactly this single-server check.
2455
+ // Left in place unchanged (rather than deleted/rewritten to delegate to
2456
+ // the new gate) per #814's scope: a future cleanup could fold this block
2457
+ // into the general one once both have soaked, but that's a separate,
2458
+ // lower-risk follow-up, not bundled into this fix.
2459
+ if (diagnosticsTimedOut &&
2460
+ !notifyWriteTimedOut &&
2461
+ !tsserverSyncEligible &&
2462
+ clientScope === "primary" &&
2463
+ spawned.length === 1 &&
2464
+ getStrategy(spawned[0].client.serverId, spawned[0].client.getLaunchVariant?.()).silentOnClean === true) {
1586
2465
  try {
1587
- const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
1588
- !entry.client.getAllDiagnostics().has(normalizedPath));
1589
- if (outstanding.length > 0) {
1590
- const snapshots = await this.getCapabilitySnapshots(filePath);
1591
- const allSilent = outstanding.every((entry) => classifyServerWaitTier(entry.client.serverId, snapshots.find((s) => s.serverId === entry.client.serverId)) === "tier3-silent");
1592
- if (allSilent) {
2466
+ const snapshots = await this.getCapabilitySnapshots(filePath);
2467
+ if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
2468
+ // #1277: same liveness precondition as the aggregate gate above —
2469
+ // `tier3-silent` is a static capability classification and can't
2470
+ // distinguish "silent because clean" from "silent because wedged".
2471
+ // A cheap bounded round-trip proves the server is still actually
2472
+ // responding before its silence is trusted as a clean confirm; a
2473
+ // server that doesn't answer in time leaves the touch inconclusive.
2474
+ const alive = await (spawned[0].client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false);
2475
+ if (alive) {
1593
2476
  diagnosticsTimedOut = false;
2477
+ if (collected !== undefined)
2478
+ collected = mergeLspDiagnostics([]);
1594
2479
  logLatency({
1595
2480
  type: "phase",
1596
2481
  phase: "lsp_silent_clean_confirm",
@@ -1600,238 +2485,212 @@ export class LSPService {
1600
2485
  source,
1601
2486
  clientScope,
1602
2487
  diagnosticsMode,
1603
- aggregate: true,
1604
- serverIds: outstanding.map((entry) => entry.client.serverId),
2488
+ serverId: spawned[0].client.serverId,
1605
2489
  },
1606
2490
  });
1607
2491
  }
1608
2492
  }
1609
2493
  }
1610
2494
  catch {
1611
- // Fail-safe: leave `diagnosticsTimedOut` as-is — today's
1612
- // inconclusive behavior, exactly like the single-server gate.
2495
+ // Fail-safe: leave `diagnosticsTimedOut` as-is — today's inconclusive
2496
+ // behavior.
1613
2497
  }
1614
2498
  }
1615
- }
1616
- // #707: when the racing sync confirm won the wait, its answer IS the
1617
- // collected result — the file's real syntactic + semantic state straight
1618
- // from tsserver (clean = [], dirty = real findings that a silentOnClean
1619
- // server had computed but never published). Otherwise merge the push
1620
- // diagnostics from the client cache as always.
1621
- let collected = options.collectDiagnostics
1622
- ? tsserverSyncConfirmed !== undefined
1623
- ? mergeLspDiagnostics(tsserverSyncConfirmed)
1624
- : mergeLspDiagnostics(spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)))
1625
- : undefined;
1626
- // #707 end-of-wait fallback: when the racing confirm did NOT decide the
1627
- // wait (sync unavailable/failed mid-race, or push resolved as a bare
1628
- // timeout) and the wait timed out with an empty result on an eligible
1629
- // touch, give the sync clean-confirm one last shot before reporting
1630
- // inconclusive. `tsserverSyncEligible` already encodes every gate (notify
1631
- // succeeded, collecting, primary scope, tier3-silent classic typescript —
1632
- // native-ts7 excluded by `classifyCascadeWaitTier`). If the sync call
1633
- // answers (even with an empty body, which is a confirmed clean), we use
1634
- // those diagnostics as the confirmed result and clear the
1635
- // `diagnosticsTimedOut` flag so the touch is no longer treated as
1636
- // inconclusive. Sync diagnostics on a dirty file are surfaced, not
1637
- // discarded. If the sync call fails or is unavailable, we fall through to
1638
- // today's behavior: `inconclusive` = true, `collected` unchanged. This
1639
- // turns "unconfirmed after ~1000ms" into "confirmed at ~wait+sync-RTT"
1640
- // even when the race path couldn't answer.
1641
- if (diagnosticsTimedOut &&
1642
- tsserverSyncEligible &&
1643
- collected !== undefined &&
1644
- collected.length === 0) {
1645
- try {
1646
- const syncResult = await attemptTsserverSyncDiagnostics(filePath, this);
1647
- if (syncResult !== undefined) {
1648
- // Sync answered — confirmed result (clean or with diagnostics).
1649
- // Clear the timed-out flag so the touch is no longer inconclusive.
1650
- diagnosticsTimedOut = false;
1651
- collected = syncResult.length > 0
1652
- ? mergeLspDiagnostics(syncResult)
1653
- : [];
1654
- logLatency({
1655
- type: "phase",
1656
- phase: "lsp_tsserver_sync_confirm",
1657
- filePath: normalizedPath,
1658
- durationMs: Date.now() - startedAt,
1659
- metadata: {
1660
- source,
1661
- clientScope,
1662
- diagnosticsMode,
1663
- mode: "end-of-wait",
1664
- confirmedDiagnosticCount: collected.length,
1665
- },
1666
- });
2499
+ // A touch is inconclusive when EITHER the notify write or the
2500
+ // diagnostics wait hit their deadline for ANY of the spawned servers
2501
+ // (these flags are touch-wide, covering the whole `Promise.all` over
2502
+ // `spawned` — see the field doc on the return type). We deliberately
2503
+ // err toward caution here: `collected` merges diagnostics across every
2504
+ // spawned server, so even a partial timeout (e.g. a slow auxiliary
2505
+ // while the primary answered) means the merged result may be missing
2506
+ // findings that just hadn't arrived yet — it must not be trusted as a
2507
+ // confirmed answer.
2508
+ const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
2509
+ // #1470: an auxiliary whose push wait was CUT OFF by the aux grace timer
2510
+ // (R8/#714) contributed exactly as much evidence about this file as one that
2511
+ // went silent inside its own budget — none. A hung opengrep resolved
2512
+ // `confirmation: "confirmed"` and read as confirmed-clean on the security
2513
+ // lane. (The SILENT case still does, and is NOT addressed here: a scanner
2514
+ // that settles inside its own budget without publishing also yields an
2515
+ // unqualified confirmation. Same #533 class, same lane, different door —
2516
+ // filed as #1493, deliberately untouched by this change.)
2517
+ // This does NOT flip the touch to inconclusive: that would discard a
2518
+ // primary answer that IS trustworthy (#533 honesty doctrine cuts both ways —
2519
+ // overclaiming and underclaiming are both dishonest). Instead the confirmation
2520
+ // is NARROWED: `"partial"`, naming the servers it does not speak for, so every
2521
+ // consumer that treats confirmation as proof of coverage fails closed while
2522
+ // the primary's findings still flow.
2523
+ const unconfirmedServerIds = auxCutOffServerIds ?? [];
2524
+ const coverageGap = unconfirmedServerIds.length > 0;
2525
+ // #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
2526
+ // "actually warm" signal `ensureWarmForSweep` waits for — mark every
2527
+ // spawned server so a later sweep in this session sees the check as a
2528
+ // no-op instead of paying the warm-up round trip again.
2529
+ //
2530
+ // #743: the diagnostics wait is a blanket (touch-wide) gate, but the
2531
+ // notify-write timeout is now PER-SERVER — a healthy server whose sibling's
2532
+ // write stalled must still be eligible, so only servers whose OWN write
2533
+ // timed out are skipped here (rather than gating the whole loop on the
2534
+ // file-level `inconclusive`).
2535
+ //
2536
+ // #1470: same per-server reasoning for a CUT-OFF auxiliary. "Demonstrated
2537
+ // ready" means this server answered for this file; an auxiliary our grace
2538
+ // timer cut off demonstrably did not.
2539
+ //
2540
+ // NO TEST PINS THIS LINE, and that is a property of today's readers rather
2541
+ // than a coverage gap: `ensureWarmForSweep` filters `role === "auxiliary"`
2542
+ // out of its server list entirely, so no reader consumes an auxiliary's
2543
+ // `demonstratedReady` mark and deleting this `continue` changes no observable
2544
+ // behavior (verified by mutation — the LSP suite stays green). It stays
2545
+ // because the mark's meaning is "this server answered", and the moment any
2546
+ // reader stops filtering auxiliaries out, marking a cut-off scanner warm
2547
+ // would let it skip a warm-up it never earned.
2548
+ const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
2549
+ const cutOffServerIds = new Set(unconfirmedServerIds);
2550
+ if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
2551
+ for (const entry of spawned) {
2552
+ if (notifyTimedOutServerIds.has(entry.info.id))
2553
+ continue;
2554
+ if (cutOffServerIds.has(entry.info.id))
2555
+ continue;
2556
+ const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
2557
+ if (key)
2558
+ this.markDemonstratedReadyKey(key);
1667
2559
  }
1668
2560
  }
1669
- catch {
1670
- // Any failure here falls through to today's inconclusive behavior.
1671
- }
1672
- }
1673
- // #799: generalize the "silent-clean push-only" confirm beyond
1674
- // typescript's active sync-command race above. That mechanism is
1675
- // TS-specific (`attemptTsserverSyncDiagnostics` races an actual
1676
- // `typescript.tsserverRequest` — no equivalent protocol exists for
1677
- // e.g. marksman) and is now scoped to `serverId === "typescript"`
1678
- // only (see the gate above), so it never fires for another
1679
- // `silentOnClean` server. This is the generic fallback for those
1680
- // servers: if the wait ran its full budget with a successful notify
1681
- // write and nothing published, and the live capability snapshot
1682
- // classifies this touch as tier3-silent (push-only + `silentOnClean`,
1683
- // #458's `classifyCascadeWaitTier`), that is not "still working" —
1684
- // `silentOnClean` means by definition this server publishes NOTHING
1685
- // on a clean transition, so a timeout under those conditions IS the
1686
- // confirmed-clean answer. `!tsserverSyncEligible` keeps this from
1687
- // ever double-deciding typescript's own touches — when the sync race
1688
- // was attempted and failed/was unavailable, typescript's existing
1689
- // "falls through to inconclusive, unchanged" contract (#707) is
1690
- // preserved exactly; typescript touches that never enter that gate
1691
- // (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
1692
- // warm-up call) are still eligible here as a genuine bonus fix. Scoped
1693
- // to `clientScope === "primary"`/`spawned.length === 1` exactly like
1694
- // the sync-eligible gate above (and like `ensureWarmForSweep`'s own
1695
- // `clientScope: "primary"` warm-up touch) so a multi-server
1696
- // with-auxiliary/all touch — where a partial timeout must stay
1697
- // cautious per the doc below — is never affected.
1698
- //
1699
- // #814: this is now a SPECIAL CASE of the more general per-server gate
1700
- // above (the `clientScope === "all"` block right before the diagnostics-
1701
- // wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
1702
- // server is tier3-silent" collapses to exactly this single-server check.
1703
- // Left in place unchanged (rather than deleted/rewritten to delegate to
1704
- // the new gate) per #814's scope: a future cleanup could fold this block
1705
- // into the general one once both have soaked, but that's a separate,
1706
- // lower-risk follow-up, not bundled into this fix.
1707
- if (diagnosticsTimedOut &&
1708
- !notifyWriteTimedOut &&
1709
- !tsserverSyncEligible &&
1710
- clientScope === "primary" &&
1711
- spawned.length === 1 &&
1712
- getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1713
- try {
1714
- const snapshots = await this.getCapabilitySnapshots(filePath);
1715
- if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
1716
- diagnosticsTimedOut = false;
1717
- if (collected !== undefined)
1718
- collected = mergeLspDiagnostics([]);
1719
- logLatency({
1720
- type: "phase",
1721
- phase: "lsp_silent_clean_confirm",
1722
- filePath: normalizedPath,
1723
- durationMs: Date.now() - startedAt,
1724
- metadata: {
1725
- source,
1726
- clientScope,
1727
- diagnosticsMode,
1728
- serverId: spawned[0].client.serverId,
1729
- },
1730
- });
2561
+ // Prime the last-known cache WITH the hash of the content we just synced,
2562
+ // so a hot-path consumer (actionable-warnings at turn_end) can verify the
2563
+ // cached diagnostics are for the current bytes before reusing them instead
2564
+ // of paying for a second open+wait. Only when we actually collected — a
2565
+ // non-collecting touch (didChange-only) leaves the prior entry intact.
2566
+ // Skip this entirely when the touch was inconclusive: an unconfirmed
2567
+ // empty `collected` must never erase a previously-confirmed non-empty
2568
+ // record (that's the #570 bug — a timeout silently reporting as clean
2569
+ // and wiping out known-good diagnostic state).
2570
+ // #1470: a PARTIAL touch is the same hazard wearing a different flag. Its
2571
+ // merged array is missing whatever the cut-off auxiliary would have said, so
2572
+ // priming the cache with it would let `actionable-warnings`' hash-guarded
2573
+ // read replay a partially-covered result as an authoritative observation —
2574
+ // and an empty one would DELETE a previously-confirmed record on the strength
2575
+ // of a scanner that never answered. Skip the prime; the next read pays a real
2576
+ // round trip instead of trusting an incomplete one.
2577
+ if (collected !== undefined && !inconclusive && !coverageGap) {
2578
+ const normalizedKey = normalizeMapKey(filePath);
2579
+ if (collected.length > 0) {
2580
+ this.lastKnownDiagnostics.set(normalizedKey, collected);
2581
+ this.lastKnownContentHash.set(normalizedKey, this.hashContent(content));
2582
+ }
2583
+ else {
2584
+ this.lastKnownDiagnostics.delete(normalizedKey);
2585
+ this.lastKnownContentHash.delete(normalizedKey);
1731
2586
  }
1732
2587
  }
1733
- catch {
1734
- // Fail-safe: leave `diagnosticsTimedOut` as-is — today's inconclusive
1735
- // behavior.
1736
- }
1737
- }
1738
- // A touch is inconclusive when EITHER the notify write or the
1739
- // diagnostics wait hit their deadline for ANY of the spawned servers
1740
- // (these flags are touch-wide, covering the whole `Promise.all` over
1741
- // `spawned` — see the field doc on the return type). We deliberately
1742
- // err toward caution here: `collected` merges diagnostics across every
1743
- // spawned server, so even a partial timeout (e.g. a slow auxiliary
1744
- // while the primary answered) means the merged result may be missing
1745
- // findings that just hadn't arrived yet — it must not be trusted as a
1746
- // confirmed answer.
1747
- const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
1748
- // #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
1749
- // "actually warm" signal `ensureWarmForSweep` waits for — mark every
1750
- // spawned server so a later sweep in this session sees the check as a
1751
- // no-op instead of paying the warm-up round trip again.
1752
- //
1753
- // #743: the diagnostics wait is a blanket (touch-wide) gate, but the
1754
- // notify-write timeout is now PER-SERVER — a healthy server whose sibling's
1755
- // write stalled must still be eligible, so only servers whose OWN write
1756
- // timed out are skipped here (rather than gating the whole loop on the
1757
- // file-level `inconclusive`).
1758
- const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
1759
- if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
1760
- for (const entry of spawned) {
1761
- if (notifyTimedOutServerIds.has(entry.info.id))
1762
- continue;
1763
- const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
1764
- if (key)
1765
- this.markDemonstratedReadyKey(key);
1766
- }
1767
- }
1768
- // Prime the last-known cache WITH the hash of the content we just synced,
1769
- // so a hot-path consumer (actionable-warnings at turn_end) can verify the
1770
- // cached diagnostics are for the current bytes before reusing them instead
1771
- // of paying for a second open+wait. Only when we actually collected — a
1772
- // non-collecting touch (didChange-only) leaves the prior entry intact.
1773
- // Skip this entirely when the touch was inconclusive: an unconfirmed
1774
- // empty `collected` must never erase a previously-confirmed non-empty
1775
- // record (that's the #570 bug — a timeout silently reporting as clean
1776
- // and wiping out known-good diagnostic state).
1777
- if (collected !== undefined && !inconclusive) {
1778
- const normalizedKey = normalizeMapKey(filePath);
1779
- if (collected.length > 0) {
1780
- this.lastKnownDiagnostics.set(normalizedKey, collected);
1781
- this.lastKnownContentHash.set(normalizedKey, this.hashContent(content));
2588
+ // #1179 (shape-5 structural fix): build the result WRAPPER. The two flags
2589
+ // that used to ride the returned array as NON-enumerable side-channels
2590
+ // (`Object.defineProperty(collected, ...)`, dropped by any `[...]`/`.filter`/
2591
+ // `JSON` copy — the #1094/#1096 loss class) are now EXPLICIT ENUMERABLE
2592
+ // fields on this wrapper. `.diags` holds the array a copy operates on, so the
2593
+ // flags survive by construction. Field presence mirrors the old attachment
2594
+ // conditions EXACTLY: `inconclusive` only for a confirmed-inconclusive
2595
+ // collect, `binding` only for a collecting touch — a non-collecting touch
2596
+ // keeps resolving `{ diags: [] }`, no flags.
2597
+ const result = { diags: collected ?? [] };
2598
+ if (collected !== undefined && inconclusive) {
2599
+ result.inconclusive = true;
1782
2600
  }
1783
- else {
1784
- this.lastKnownDiagnostics.delete(normalizedKey);
1785
- this.lastKnownContentHash.delete(normalizedKey);
2601
+ else if (collected !== undefined && coverageGap) {
2602
+ // #1470: narrowed, not collapsed. The primary's findings ride along in
2603
+ // `.diags` exactly as before; what changes is that the touch now states
2604
+ // which servers it does not speak for, so no consumer can read this as a
2605
+ // full clean bill of health.
2606
+ result.confirmation = "partial";
2607
+ result.unconfirmedServerIds = [...unconfirmedServerIds];
1786
2608
  }
1787
- }
1788
- if (collected !== undefined && inconclusive) {
1789
- // Non-enumerable so JSON.stringify / spread / logging of the
1790
- // diagnostics array is unaffected — this is a query-only bonus
1791
- // field, not a shape change existing array consumers need to know
1792
- // about.
1793
- Object.defineProperty(collected, "inconclusive", {
1794
- value: true,
1795
- enumerable: false,
1796
- configurable: true,
2609
+ else if (collected !== undefined) {
2610
+ // Preserve the lower-level affirmative result across consumers. In
2611
+ // particular, the silent-clean gates above clear diagnosticsTimedOut only
2612
+ // after a successful notify and capability-confirmed wait; reclassifying
2613
+ // that empty array later would discard the evidence that made it clean.
2614
+ result.confirmation = "confirmed";
2615
+ }
2616
+ // #1095: attach the merged content binding so a consumer can ask whether
2617
+ // these diagnostics were computed against current disk. Composed across every
2618
+ // spawned client so a single client whose view diverged from disk marks the
2619
+ // whole merged result mismatched. Disk verify is lazy + memoized per
2620
+ // (file, mtime).
2621
+ let binding;
2622
+ if (collected !== undefined) {
2623
+ binding = syncConfirmed
2624
+ ? // #1095 (P3-b): a tsserver sync-confirmed result is authoritative for
2625
+ // the current buffer but not tied to the publish-path fingerprint —
2626
+ // surface "unknown" so a stale publish binding can't demote it.
2627
+ { boundToCurrentDisk: "unknown" }
2628
+ : this.mergeBinding(filePath,
2629
+ // Optional-chain so a client without the getter (test doubles, a
2630
+ // partially-mocked client) yields "unknown" rather than throwing —
2631
+ // unknown preserves pre-#1095 behavior for that contributor.
2632
+ [
2633
+ ...spawned.map((entry) => entry.client.getDiagnosticBinding?.(filePath)),
2634
+ ...carriedAuxiliary.map((entry) => entry.binding),
2635
+ ]);
2636
+ result.binding = binding;
2637
+ }
2638
+ // The recent-touches entries are recorded per server inside the notify-write
2639
+ // loop above, at the moment each server's own write lands (#1253) — a server
2640
+ // whose write timed out or rejected deliberately gets none, so the next touch
2641
+ // re-pushes it rather than debouncing a failure into a later touch that looks
2642
+ // fully delivered. Nothing to record here: a skipped server keeps its original
2643
+ // entry (and timestamp) so its window still expires naturally instead of being
2644
+ // extended by every reuse.
2645
+ logLatency({
2646
+ type: "phase",
2647
+ phase: "lsp_touch_file",
2648
+ filePath: normalizedPath,
2649
+ durationMs: Date.now() - startedAt,
2650
+ metadata: {
2651
+ serverCountReady: spawned.length,
2652
+ clientScope,
2653
+ diagnosticsMode,
2654
+ source,
2655
+ failureKind: "success",
2656
+ collectedDiagnostics: collected?.length,
2657
+ // #1095: observability so an unbinding server is diagnosable —
2658
+ // "bound" (matches disk) / "mismatch" (diverged) / "unknown"
2659
+ // (version-less server or disk unreadable). Absent for non-collecting
2660
+ // touches (no merged result to bind).
2661
+ ...(binding !== undefined && {
2662
+ bindingState: bindingStateLabel(binding.boundToCurrentDisk),
2663
+ }),
2664
+ notifySkipped,
2665
+ notifyWriteTimedOut,
2666
+ // #743: per-server detail — which servers' writes actually timed out.
2667
+ // Absent when none did. `notifyWriteTimedOut` is the file-level "at
2668
+ // least one" summary.
2669
+ ...(notifyWriteTimedOutServerIds.length > 0 && {
2670
+ notifyWriteTimedOutServerIds,
2671
+ }),
2672
+ diagnosticsTimedOut,
2673
+ inconclusive,
2674
+ // #1470: the touch's own honesty verdict, so a `cut_off` row in
2675
+ // `lsp_aux_wait_outcome` can be joined to the touch that produced it and
2676
+ // shown NOT to have claimed confirmation for that server's coverage.
2677
+ // Absent for a non-collecting touch, which claims nothing either way.
2678
+ ...(result.confirmation !== undefined && {
2679
+ confirmation: result.confirmation,
2680
+ }),
2681
+ // R8 (#714): server ids of auxiliaries whose push wait was cut off by
2682
+ // the aux grace window (primary settled clean + aux timed out in grace).
2683
+ // Absent when no aux was cut off. These servers' diagnostics are
2684
+ // advisory-only and will surface on the next edit from their cache.
2685
+ ...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
2686
+ },
1797
2687
  });
2688
+ return result;
1798
2689
  }
1799
- // Only refresh the recent-touches entry when we actually pushed. Skipping
1800
- // here keeps the original push timestamp intact so the debounce window
1801
- // expires naturally instead of being extended by every reuse.
1802
- if (!notifySkipped) {
1803
- this.markTouched(filePath, content, clientScope);
2690
+ finally {
2691
+ for (const key of leaseKeys)
2692
+ this.releaseClientLease(key);
1804
2693
  }
1805
- logLatency({
1806
- type: "phase",
1807
- phase: "lsp_touch_file",
1808
- filePath: normalizedPath,
1809
- durationMs: Date.now() - startedAt,
1810
- metadata: {
1811
- serverCountReady: spawned.length,
1812
- clientScope,
1813
- diagnosticsMode,
1814
- source,
1815
- failureKind: "success",
1816
- collectedDiagnostics: collected?.length,
1817
- notifySkipped,
1818
- notifyWriteTimedOut,
1819
- // #743: per-server detail — which servers' writes actually timed out.
1820
- // Absent when none did. `notifyWriteTimedOut` is the file-level "at
1821
- // least one" summary.
1822
- ...(notifyWriteTimedOutServerIds.length > 0 && {
1823
- notifyWriteTimedOutServerIds,
1824
- }),
1825
- diagnosticsTimedOut,
1826
- inconclusive,
1827
- // R8 (#714): server ids of auxiliaries whose push wait was cut off by
1828
- // the aux grace window (primary settled clean + aux timed out in grace).
1829
- // Absent when no aux was cut off. These servers' diagnostics are
1830
- // advisory-only and will surface on the next edit from their cache.
1831
- ...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
1832
- },
1833
- });
1834
- return collected ?? [];
1835
2694
  }
1836
2695
  /**
1837
2696
  * Get diagnostics for a file
@@ -1916,7 +2775,8 @@ export class LSPService {
1916
2775
  }
1917
2776
  const clientWaits = spawned.map(async (entry) => {
1918
2777
  const waitStart = Date.now();
1919
- const strategy = getStrategy(entry.info.id);
2778
+ const launchVariant = entry.client.getLaunchVariant?.();
2779
+ const strategy = getStrategy(entry.info.id, launchVariant);
1920
2780
  await entry.client.waitForDiagnostics(filePath, strategy.aggregateWaitMs);
1921
2781
  let diagnostics = entry.client.getDiagnostics(filePath);
1922
2782
  const firstWaitMs = Date.now() - waitStart;
@@ -1928,6 +2788,7 @@ export class LSPService {
1928
2788
  }
1929
2789
  return {
1930
2790
  serverId: entry.info.id,
2791
+ launchVariant,
1931
2792
  waitMs: Date.now() - waitStart,
1932
2793
  diagnosticCount: diagnostics.length,
1933
2794
  diagnostics,
@@ -1936,25 +2797,48 @@ export class LSPService {
1936
2797
  // Document mode: 0ms grace — return as soon as any client has results.
1937
2798
  // Full mode: 400ms grace — wait a bit for other clients to catch up.
1938
2799
  const graceMs = diagnosticsMode === "document" ? 0 : EARLY_UNBLOCK_GRACE_MS;
1939
- // R8 (#714): per-promise role descriptors so raceToCompletion can apply
1940
- // a bounded aux grace once all primary-role promises have settled.
1941
- // Servers with role:"auxiliary" (opengrep, ast-grep, zizmor, …) get at
1942
- // most PI_LENS_AUX_GRACE_MS (default 500ms) after the primary settles;
1943
- // late arrivals are dropped (advisory only — they land in the client
2800
+ // R8 (#714) / #1458 S2: per-promise role descriptors so raceToCompletion
2801
+ // can apply a bounded aux grace once all primary-role promises have
2802
+ // settled. Servers with role:"auxiliary" (opengrep, ast-grep, zizmor, …)
2803
+ // get their OWN declared `aggregateWaitMs` budget after the primary
2804
+ // settles, capped by the PI_LENS_AUX_GRACE_MS global ceiling (default
2805
+ // 2000ms) — the same "declared budget, capped by a ceiling" shape
2806
+ // `touchFile`'s with-auxiliary push wait uses, so this lane can no longer
2807
+ // starve a scanner whose measured warm run (e.g. opengrep ~1.3s) is
2808
+ // shorter than the ceiling but longer than a flat short grace. Late
2809
+ // arrivals are still dropped (advisory only — they land in the client
1944
2810
  // cache and surface on the next edit). Primary-only callers have no
1945
2811
  // auxiliary descriptors, so this path is never entered and there is
1946
2812
  // zero behavior change for the single-server hot path.
1947
- const diagDescriptors = spawned.map((entry) => ({
1948
- role: entry.info.role === "auxiliary" ? "auxiliary" : "primary",
1949
- }));
2813
+ const diagDescriptors = spawned.map((entry) => {
2814
+ if (entry.info.role !== "auxiliary")
2815
+ return { role: "primary" };
2816
+ const strategy = getStrategy(entry.info.id, entry.client.getLaunchVariant?.());
2817
+ // Deleting this `budgetMs` fails no end-to-end test, and that is
2818
+ // expected rather than a coverage gap: every promise in `clientWaits`
2819
+ // already self-bounds at this same `strategy.aggregateWaitMs`, so an
2820
+ // auxiliary cuts itself off at its declared budget whether or not the
2821
+ // shared grace timer also knows about it. The descriptor is what keeps
2822
+ // the two agreeing — it is the mitigation for the over-granting
2823
+ // `raceToCompletion` documents, and it starts mattering the moment a
2824
+ // promise here stops self-bounding. The narrowing itself is pinned in
2825
+ // tests/clients/lsp/aggregation.test.ts, which can build the
2826
+ // non-self-bounded promises this path cannot.
2827
+ return { role: "auxiliary", budgetMs: strategy.aggregateWaitMs };
2828
+ });
1950
2829
  // Result-aware racing: trigger early-unblock when any client has results,
1951
2830
  // OR when a seedFirstPush server returns (its first push is authoritative
1952
2831
  // even when empty — waiting longer yields nothing more).
1953
- const perServer = await raceToCompletion(clientWaits, (results) => results.some((r) => r.diagnosticCount > 0 || getStrategy(r.serverId).seedFirstPush), {
1954
- timeoutMs: Math.max(...spawned.map((entry) => getStrategy(entry.info.id).aggregateWaitMs)),
2832
+ const perServer = await raceToCompletion(clientWaits, (results) => results.some((r) => r.diagnosticCount > 0 ||
2833
+ getStrategy(r.serverId, r.launchVariant).seedFirstPush), {
2834
+ timeoutMs: Math.max(...spawned.map((entry) => getStrategy(entry.info.id, entry.client.getLaunchVariant?.())
2835
+ .aggregateWaitMs)),
1955
2836
  graceMs,
1956
2837
  descriptors: diagDescriptors,
1957
- auxGraceMs: readEnvAuxGraceMs(),
2838
+ // #1458 S2: ceiling, not a flat wait — each auxiliary's own
2839
+ // budgetMs (above) determines the actual per-touch grace up to
2840
+ // this cap. Matches touchFile's `readEnvAuxGraceMs() ?? 2000`.
2841
+ auxGraceMs: readEnvAuxGraceMs() ?? 2000,
1958
2842
  });
1959
2843
  // Fill in any slots that timed out before producing results.
1960
2844
  const earlyUnblockedCount = spawned.length - perServer.length;
@@ -1962,7 +2846,8 @@ export class LSPService {
1962
2846
  const found = perServer.find((r) => r.serverId === entry.info.id);
1963
2847
  return (found ?? {
1964
2848
  serverId: entry.info.id,
1965
- waitMs: getStrategy(entry.info.id).aggregateWaitMs,
2849
+ launchVariant: entry.client.getLaunchVariant?.(),
2850
+ waitMs: getStrategy(entry.info.id, entry.client.getLaunchVariant?.()).aggregateWaitMs,
1966
2851
  diagnosticCount: 0,
1967
2852
  diagnostics: [],
1968
2853
  });
@@ -2036,6 +2921,40 @@ export class LSPService {
2036
2921
  hashContent(content) {
2037
2922
  return createHash("sha256").update(content).digest("hex");
2038
2923
  }
2924
+ /**
2925
+ * #1095: compose the content `binding` for a merged diagnostics result across
2926
+ * the clients that contributed to it (primary + any auxiliaries). Verifies
2927
+ * each contributor's stored binding against current disk lazily (memoized per
2928
+ * file+mtime by {@link diskBindingCache}) and composes per
2929
+ * {@link composeBoundToCurrentDisk}: ANY contributor mismatches disk → false;
2930
+ * else all "unknown" (or no contributors) → "unknown"; else true. The
2931
+ * surfaced version/contentHash come from the first contributor that carries
2932
+ * them — informational only; the load-bearing field is `boundToCurrentDisk`.
2933
+ * #1104: `version` and `contentHash` are tracked INDEPENDENTLY (not gated on
2934
+ * the same contributor carrying both) — a pull-sourced binding deliberately
2935
+ * has no `version` (the pull protocol has no version-echo concept) but does
2936
+ * carry a real `contentHash`; gating the latter on the former would silently
2937
+ * drop it here even though `boundToCurrentDisk` above already used it.
2938
+ */
2939
+ mergeBinding(filePath, stored) {
2940
+ const verdicts = [];
2941
+ let version;
2942
+ let contentHash;
2943
+ for (const entry of stored) {
2944
+ verdicts.push(this.diskBindingCache.boundToCurrentDisk(filePath, entry ?? {}));
2945
+ if (version === undefined && entry?.version !== undefined) {
2946
+ version = entry.version;
2947
+ }
2948
+ if (contentHash === undefined && entry?.contentHash !== undefined) {
2949
+ contentHash = entry.contentHash;
2950
+ }
2951
+ }
2952
+ return {
2953
+ version,
2954
+ contentHash,
2955
+ boundToCurrentDisk: composeBoundToCurrentDisk(verdicts),
2956
+ };
2957
+ }
2039
2958
  /**
2040
2959
  * Navigation: go to definition
2041
2960
  */
@@ -2134,18 +3053,18 @@ export class LSPService {
2134
3053
  * advertisement in the client). If filePath is given, target that file's
2135
3054
  * server; otherwise the first active client.
2136
3055
  */
2137
- async executeCommand(filePath, command, args) {
3056
+ async executeCommand(filePath, command, args, mutationContext) {
2138
3057
  if (filePath) {
2139
3058
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2140
3059
  if (!spawned) {
2141
3060
  return { executed: false, reason: "no LSP server for file" };
2142
3061
  }
2143
- return spawned.client.executeCommand(command, args);
3062
+ return spawned.client.executeCommand(command, args, mutationContext);
2144
3063
  }
2145
3064
  const first = this.state.clients.values().next().value;
2146
3065
  if (!first)
2147
3066
  return { executed: false, reason: "no active LSP server" };
2148
- return first.executeCommand(command, args);
3067
+ return first.executeCommand(command, args, mutationContext);
2149
3068
  }
2150
3069
  /**
2151
3070
  * Capability snapshot for LSP operations.
@@ -2180,7 +3099,7 @@ export class LSPService {
2180
3099
  if (filePath) {
2181
3100
  const servers = getServersForFileWithConfig(filePath);
2182
3101
  for (const server of servers) {
2183
- const root = await server.root(filePath);
3102
+ const root = await this.resolveServerRoot(server, filePath);
2184
3103
  if (!root)
2185
3104
  continue;
2186
3105
  const client = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
@@ -2254,6 +3173,20 @@ export class LSPService {
2254
3173
  async renameFile(oldFilePath, newFilePath, options) {
2255
3174
  const cwd = options.cwd;
2256
3175
  const apply = options.apply ?? false;
3176
+ // Validate the complete resource operation before asking any server for
3177
+ // willRenameFiles edits. This is a read-only preflight, but it reuses the
3178
+ // same confinement, realpath/symlink, existence, and destination checks as
3179
+ // the eventual apply path, so an invalid rename cannot first mutate an
3180
+ // in-workspace file through returned text edits (including previews).
3181
+ await validateWorkspaceEdit({
3182
+ documentChanges: [
3183
+ {
3184
+ kind: "rename",
3185
+ oldUri: pathToFileURL(oldFilePath).href,
3186
+ newUri: pathToFileURL(newFilePath).href,
3187
+ },
3188
+ ],
3189
+ }, cwd);
2257
3190
  const priorityServerIds = getServersForFileWithConfig(oldFilePath).map((server) => server.id);
2258
3191
  const activeClients = this.activeClientsForCwd(cwd, priorityServerIds);
2259
3192
  const willRenameFailures = [];
@@ -2290,25 +3223,142 @@ export class LSPService {
2290
3223
  summary,
2291
3224
  };
2292
3225
  }
2293
- const applied = await applyWorkspaceEdit(merged.edit, cwd);
2294
- await fs.mkdir(path.dirname(newFilePath), { recursive: true });
2295
- await fs.rename(oldFilePath, newFilePath);
3226
+ let applied;
3227
+ try {
3228
+ applied = await applyWorkspaceEdit(merged.edit, cwd, {
3229
+ mutationContext: options.mutationContext,
3230
+ observe: false,
3231
+ });
3232
+ }
3233
+ catch (err) {
3234
+ if (options.mutationContext) {
3235
+ const partial = err
3236
+ .appliedWorkspaceEdit;
3237
+ if (partial) {
3238
+ recordLspMutation(options.mutationContext, {
3239
+ results: [partial],
3240
+ status: "failed",
3241
+ });
3242
+ }
3243
+ }
3244
+ throw err;
3245
+ }
3246
+ const openDocuments = activeClients
3247
+ .filter(({ client }) => client.isDocumentOpen(oldFilePath))
3248
+ .map(({ serverId, client }) => ({
3249
+ serverId,
3250
+ client,
3251
+ oldUri: client.getDocumentUri(oldFilePath),
3252
+ }));
3253
+ const closeFailures = [];
3254
+ await Promise.all(openDocuments.map(async ({ serverId, client }) => {
3255
+ try {
3256
+ await client.closeDocument(oldFilePath);
3257
+ return undefined;
3258
+ }
3259
+ catch (err) {
3260
+ closeFailures.push({
3261
+ serverId,
3262
+ error: err instanceof Error ? err.message : String(err),
3263
+ });
3264
+ return undefined;
3265
+ }
3266
+ }));
3267
+ if (closeFailures.length > 0) {
3268
+ if (options.mutationContext) {
3269
+ recordLspMutation(options.mutationContext, {
3270
+ results: [applied],
3271
+ status: "failed",
3272
+ });
3273
+ }
3274
+ // Do not rename or send didRenameFiles while any server still has the
3275
+ // old document open. Re-open/resync every affected client so a partial
3276
+ // close cannot leave an in-memory document behind the disk contents.
3277
+ // Reopen with the document's ACTUAL language ID (the same resolver every
3278
+ // genuine open uses) rather than a hardcoded "plaintext" fallback — a
3279
+ // wrong languageId here degrades that server's diagnostics until the
3280
+ // next genuine open (#1147 P3-7).
3281
+ const content = await fs.readFile(oldFilePath, "utf-8");
3282
+ const languageId = getLanguageId(oldFilePath) ?? "plaintext";
3283
+ await Promise.all(openDocuments.map(({ client }) => client.notify.open(oldFilePath, content, languageId, true, true)));
3284
+ throw new Error(`workspace/didClose failed; rename aborted: ${closeFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
3285
+ }
3286
+ let renameApplied;
3287
+ try {
3288
+ // Route the resource mutation through the same preflight confinement,
3289
+ // realpath and symlink policy as all other workspace edits. Do not
3290
+ // duplicate that security boundary with a direct mkdir/rename pair.
3291
+ renameApplied = await applyWorkspaceEdit({
3292
+ documentChanges: [
3293
+ {
3294
+ kind: "rename",
3295
+ oldUri: pathToFileURL(oldFilePath).href,
3296
+ newUri: pathToFileURL(newFilePath).href,
3297
+ },
3298
+ ],
3299
+ }, cwd, { observe: false });
3300
+ }
3301
+ catch (err) {
3302
+ if (options.mutationContext) {
3303
+ recordLspMutation(options.mutationContext, {
3304
+ results: [applied],
3305
+ status: "failed",
3306
+ });
3307
+ }
3308
+ throw err;
3309
+ }
2296
3310
  const relOld = path.relative(cwd, oldFilePath).replace(/\\/g, "/") ||
2297
3311
  path.basename(oldFilePath);
2298
3312
  const relNew = path.relative(cwd, newFilePath).replace(/\\/g, "/") ||
2299
3313
  path.basename(newFilePath);
2300
3314
  const renameDescription = `Renamed ${relOld} → ${relNew}`;
2301
3315
  await Promise.all(activeClients.map(async ({ serverId, client }) => {
3316
+ const opened = openDocuments.find((entry) => entry.serverId === serverId);
2302
3317
  try {
2303
- await client.didRenameFiles(oldFilePath, newFilePath);
3318
+ if (opened?.oldUri) {
3319
+ await client.didRenameFiles(oldFilePath, newFilePath, opened.oldUri, destinationUriPreservingSpelling(opened.oldUri, oldFilePath, newFilePath));
3320
+ }
3321
+ else {
3322
+ await client.didRenameFiles(oldFilePath, newFilePath);
3323
+ }
3324
+ return undefined;
2304
3325
  }
2305
3326
  catch (err) {
2306
3327
  didRenameFailures.push({
2307
3328
  serverId,
2308
3329
  error: err instanceof Error ? err.message : String(err),
2309
3330
  });
3331
+ return undefined;
2310
3332
  }
2311
3333
  }));
3334
+ const files = [...new Set([...applied.files, oldFilePath, newFilePath])];
3335
+ if (options.mutationContext) {
3336
+ recordLspMutation(options.mutationContext, {
3337
+ results: [
3338
+ {
3339
+ ...applied,
3340
+ files,
3341
+ operationTotal: applied.operationTotal + renameApplied.operationTotal,
3342
+ appliedOperationTotal: applied.appliedOperationTotal + renameApplied.appliedOperationTotal,
3343
+ appliedOperationIndexes: [
3344
+ ...applied.appliedOperationIndexes,
3345
+ ...renameApplied.appliedOperationIndexes.map((index) => applied.operationTotal + index),
3346
+ ],
3347
+ operationCounts: {
3348
+ textEdits: applied.operationCounts.textEdits + renameApplied.operationCounts.textEdits,
3349
+ create: applied.operationCounts.create + renameApplied.operationCounts.create,
3350
+ rename: applied.operationCounts.rename + renameApplied.operationCounts.rename,
3351
+ delete: applied.operationCounts.delete + renameApplied.operationCounts.delete,
3352
+ },
3353
+ fileDetails: [
3354
+ ...applied.fileDetails,
3355
+ ...renameApplied.fileDetails,
3356
+ ],
3357
+ },
3358
+ ],
3359
+ status: "success",
3360
+ });
3361
+ }
2312
3362
  return {
2313
3363
  applied: true,
2314
3364
  serverIds: activeClients.map((entry) => entry.serverId),
@@ -2318,7 +3368,7 @@ export class LSPService {
2318
3368
  inputEditCount: merged.inputEditCount,
2319
3369
  summary,
2320
3370
  descriptions: [...applied.descriptions, renameDescription],
2321
- files: [...new Set([...applied.files, oldFilePath, newFilePath])],
3371
+ files,
2322
3372
  };
2323
3373
  }
2324
3374
  /**
@@ -2556,16 +3606,21 @@ export class LSPService {
2556
3606
  ms: timeoutMs,
2557
3607
  onTimeout: "undefined",
2558
3608
  }));
3609
+ const coldServerIds = stillColdServerIds();
2559
3610
  logLatency({
2560
3611
  type: "phase",
2561
- phase: "lsp_sweep_warmup_done",
3612
+ phase: options.signal?.aborted
3613
+ ? "lsp_sweep_warmup_aborted"
3614
+ : coldServerIds.length > 0
3615
+ ? "lsp_sweep_warmup_failed"
3616
+ : "lsp_sweep_warmup_done",
2562
3617
  filePath: representativeFile,
2563
3618
  durationMs: Date.now() - startedAt,
2564
3619
  metadata: {
2565
3620
  serverIds: servers.map((s) => s.id),
2566
3621
  timeoutMs,
2567
3622
  attempt,
2568
- coldServerIds: stillColdServerIds(),
3623
+ coldServerIds,
2569
3624
  },
2570
3625
  });
2571
3626
  };
@@ -2591,13 +3646,6 @@ export class LSPService {
2591
3646
  }
2592
3647
  }
2593
3648
  if (failedServerIds.length > 0) {
2594
- logLatency({
2595
- type: "phase",
2596
- phase: "lsp_sweep_warmup_failed",
2597
- filePath: representativeFile,
2598
- durationMs: 0,
2599
- metadata: { failedServerIds, timeoutMs },
2600
- });
2601
3649
  // #799: record the negative cache so a LATER sweep this session
2602
3650
  // skips straight past re-paying this warm-up (initial + retry).
2603
3651
  // Cleared automatically the moment the server demonstrates
@@ -2659,13 +3707,31 @@ export class LSPService {
2659
3707
  ]);
2660
3708
  const cachedResults = [];
2661
3709
  const filesToTouch = [];
3710
+ const writeIndexByPath = new Map();
2662
3711
  for (const filePath of files) {
3712
+ // Reserve order before cache lookup/touch admission. A result that settles
3713
+ // later is still ordered by when this scan began observing the file (#1198).
3714
+ writeIndexByPath.set(normalizeMapKey(filePath), options.nextWriteIndex?.());
2663
3715
  const cached = workspaceDiagnosticsCacheCtx.lookup(filePath, workspaceSweepScopeKey);
2664
- if (cached) {
3716
+ // #1095 (P2-1 bug-class sweep): both sweeps share cache entries under the
3717
+ // same scopeKey, so this service sweep must apply the SAME content-binding
3718
+ // gate the tools/lsp-diagnostics.ts sibling site does — an entry whose
3719
+ // recorded fingerprint no longer matches disk (bytes changed WITHOUT an
3720
+ // mtime bump the freshness check would catch, exactly what contentHash
3721
+ // exists to detect) must NOT be replayed and reconciled as confirmed via
3722
+ // mode=full. On a mismatch, fall through to a fresh touch.
3723
+ if (cached && cached.binding.boundToCurrentDisk !== false) {
2665
3724
  cachedResults.push({
2666
3725
  filePath,
2667
3726
  diagnostics: cached.diagnostics,
2668
3727
  count: cached.count,
3728
+ // #1093: a cache hit replays an older observation — carry its
3729
+ // scan time so mode=full's footer reconcile stamps `touchedAt`
3730
+ // with when the truth was seen, not now().
3731
+ observedAt: cached.scannedAt,
3732
+ contentHash: cached.binding.contentHash,
3733
+ boundToCurrentDisk: cached.binding.boundToCurrentDisk,
3734
+ writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
2669
3735
  });
2670
3736
  }
2671
3737
  else {
@@ -2847,8 +3913,26 @@ export class LSPService {
2847
3913
  if (attached && !attached.available) {
2848
3914
  await this.ensureWarmForSweep(filePath, { signal });
2849
3915
  }
2850
- const diagnostics = attached?.available
2851
- ? attached.response.diagnostics
3916
+ // #1179 (shape-5 structural fix): normalize both sources into the
3917
+ // `TouchFileResult` wrapper shape so the flag reads below come off
3918
+ // EXPLICIT fields, not a non-enumerable side-channel a copy would drop.
3919
+ // The warm-attach IPC branch resolves a plain diagnostics array with
3920
+ // `inconclusive` re-surfaced as an enumerable DTO field (the socket can
3921
+ // carry no binding, and the IPC client already rejects an inconclusive
3922
+ // answer, so `available` implies confirmed) — wrap it as `{ diags }`;
3923
+ // the incumbent branch already returns the wrapper.
3924
+ const touchResult = attached?.available
3925
+ ? {
3926
+ diags: attached.response.diagnostics,
3927
+ // #1470: the incumbent's coverage gap crosses the socket as an
3928
+ // explicit DTO field, so carry it onto the wrapper the sweep
3929
+ // reads. Dropping it here would let a partially covered
3930
+ // incumbent answer be persisted as a confirmed sweep result —
3931
+ // the same false clean this change closes on the local route.
3932
+ ...(attached.response.unconfirmedServerIds !== undefined && {
3933
+ unconfirmedServerIds: attached.response.unconfirmedServerIds,
3934
+ }),
3935
+ }
2852
3936
  : await withDeadline(this.touchFile(filePath, content, {
2853
3937
  diagnostics: "document",
2854
3938
  collectDiagnostics: true,
@@ -2863,17 +3947,34 @@ export class LSPService {
2863
3947
  // full wait budget only once across this whole sweep.
2864
3948
  sweepIndexGate,
2865
3949
  }), { ms: perFileMs, onTimeout: "undefined" });
3950
+ const diagnostics = touchResult?.diags;
2866
3951
  // #571: prefer #570's real per-touch inconclusive signal
2867
- // (`touchFile`'s non-enumerable `.inconclusive` flag — set when the
2868
- // notify write or the diagnostics wait itself timed out) over this
2869
- // sweep's own OUTER `perFileMs` deadline, which only catches a touch
2870
- // that never returned at all within budget. Either one means the
2871
- // result wasn't confirmed.
2872
- const inconclusive = diagnostics
2873
- ?.inconclusive === true;
2874
- const timedOut = diagnostics === undefined || inconclusive;
3952
+ // (`touchFile`'s `.inconclusive` flag — set when the notify write or the
3953
+ // diagnostics wait itself timed out) over this sweep's own OUTER
3954
+ // `perFileMs` deadline, which only catches a touch that never returned at
3955
+ // all within budget. Either one means the result wasn't confirmed.
3956
+ const inconclusive = touchResult?.inconclusive === true;
3957
+ // #1470: a cut-off auxiliary is the THIRD reason this result is not a
3958
+ // confirmed observation, and it is deliberately not `inconclusive`. The
3959
+ // record loop below persists every `!timedOut` result into the workspace
3960
+ // cache, so reading `inconclusive` alone caches a partially covered
3961
+ // answer as clean and replays it on every later sweep. Today the only
3962
+ // route that can reach this branch with a gap is the warm-attach
3963
+ // incumbent, whose touch runs `clientScope: "with-auxiliary"`; the
3964
+ // sweep's own local touch uses `clientScope: "all"`, which never arms
3965
+ // the aux grace timer at all. Both are gated here so a future scope
3966
+ // change cannot reopen the hole silently.
3967
+ const coverageGap = touchCoverageGap(touchResult).length > 0;
3968
+ const timedOut = touchResult === undefined || inconclusive || coverageGap;
2875
3969
  if (timedOut)
2876
3970
  timedOutFiles += 1;
3971
+ // #1104 (shape 5 — AGENTS.md): the touch's content binding is an
3972
+ // EXPLICIT enumerable field on the wrapper now (#1179), so it survives
3973
+ // `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
3974
+ // — read it off `touchResult`, not off the derived array. A version-
3975
+ // less/pull-less server composed no binding, so `contentHash` stays
3976
+ // undefined here (honest "unknown" downstream), matching prior behavior.
3977
+ const rawBinding = touchResult?.binding;
2877
3978
  // #586: honor each auxiliary profile's native inline-suppression
2878
3979
  // comment (e.g. opengrep's `// nosemgrep`, #441) — computed from the
2879
3980
  // raw `diagnostics` (before this drops its non-enumerable
@@ -2897,6 +3998,9 @@ export class LSPService {
2897
3998
  diagnostics: filteredDiagnostics ?? [],
2898
3999
  count: filteredDiagnostics?.length ?? 0,
2899
4000
  timedOut,
4001
+ contentHash: rawBinding?.contentHash,
4002
+ boundToCurrentDisk: rawBinding?.boundToCurrentDisk,
4003
+ writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
2900
4004
  });
2901
4005
  }
2902
4006
  catch (err) {
@@ -2909,6 +4013,7 @@ export class LSPService {
2909
4013
  // no confirmed result was obtained, so reconciliation (#571) must
2910
4014
  // skip it the same way.
2911
4015
  timedOut: true,
4016
+ writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
2912
4017
  });
2913
4018
  }
2914
4019
  completed += 1;
@@ -2948,7 +4053,10 @@ export class LSPService {
2948
4053
  const pulled = await this.tryWorkspacePull(group.files, perFileMs);
2949
4054
  if (pulled) {
2950
4055
  for (const result of pulled) {
2951
- results.push(result);
4056
+ results.push({
4057
+ ...result,
4058
+ writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
4059
+ });
2952
4060
  // #671: a pull result is always confirmed (see
2953
4061
  // `tryWorkspacePull`'s doc comment), so it's cache-eligible
2954
4062
  // too — best-effort stat since the pull already resolved the
@@ -3072,7 +4180,15 @@ export class LSPService {
3072
4180
  const scannedAt = scannedMtimeByFile.get(result.filePath);
3073
4181
  if (result.error || result.timedOut || scannedAt === undefined)
3074
4182
  continue;
3075
- workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt);
4183
+ // #1104: thread the per-result `contentHash` (from either the
4184
+ // `tryWorkspacePull` fast path or a per-file touch's own #1095 binding)
4185
+ // into the cache entry — previously this call never passed one, so
4186
+ // every pull-served entry read binding "unknown" forever and the #1095
4187
+ // P2-1 service-sweep binding gate above (`cached.binding.
4188
+ // boundToCurrentDisk !== false`) could never demote a pull-cached
4189
+ // entry. Absent `contentHash` (no binding was available) behaves
4190
+ // exactly as before.
4191
+ workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
3076
4192
  }
3077
4193
  workspaceDiagnosticsCacheCtx.persist();
3078
4194
  return [...cachedResults, ...results].filter(Boolean);
@@ -3099,14 +4215,24 @@ export class LSPService {
3099
4215
  return undefined;
3100
4216
  const byPath = new Map();
3101
4217
  for (const entry of report) {
3102
- byPath.set(normalizeMapKey(entry.filePath), entry.diagnostics);
4218
+ byPath.set(normalizeMapKey(entry.filePath), entry);
3103
4219
  }
3104
4220
  return groupFiles.map((filePath) => {
3105
- const diagnostics = byPath.get(normalizeMapKey(filePath)) ?? [];
4221
+ const entry = byPath.get(normalizeMapKey(filePath));
4222
+ const diagnostics = entry?.diagnostics ?? [];
3106
4223
  // A pull that got here returned a real workspace/diagnostic report
3107
4224
  // (see the `!report` guard above) — always confirmed, unlike a
3108
4225
  // per-file touchFile default-empty on timeout.
3109
- return { filePath, diagnostics, count: diagnostics.length, timedOut: false };
4226
+ // #1104: `contentHash` comes straight from the client's pull layer —
4227
+ // a file absent from the report (reported "clean" here) carries none,
4228
+ // same honest "unknown" the record site already applies for it.
4229
+ return {
4230
+ filePath,
4231
+ diagnostics,
4232
+ count: diagnostics.length,
4233
+ timedOut: false,
4234
+ contentHash: entry?.contentHash,
4235
+ };
3110
4236
  });
3111
4237
  }
3112
4238
  catch {
@@ -3118,6 +4244,9 @@ export class LSPService {
3118
4244
  */
3119
4245
  async getAllDiagnostics() {
3120
4246
  const all = new Map();
4247
+ // #1095: per-file stored bindings across every contributing client, merged
4248
+ // into one `boundToCurrentDisk` verdict after the client loop.
4249
+ const bindingsByPath = new Map();
3121
4250
  const now = Date.now();
3122
4251
  for (const [_key, client] of this.state.clients) {
3123
4252
  // Resolve existence asynchronously (was a blocking existsSync per tracked
@@ -3150,8 +4279,35 @@ export class LSPService {
3150
4279
  else {
3151
4280
  all.set(filePath, { diags: [...entry.diags], ts: entry.ts });
3152
4281
  }
4282
+ const list = bindingsByPath.get(filePath) ?? [];
4283
+ list.push(entry.binding);
4284
+ bindingsByPath.set(filePath, list);
3153
4285
  }
3154
4286
  }
4287
+ // #1095 (P2-2): expose the composed content binding per file LAZILY — the
4288
+ // disk stat+hash verify only runs when a consumer actually reads `.binding`.
4289
+ // The current caller (the cascade, once per edit turn) does NOT read it yet
4290
+ // (binding adoption in cascade/integration.ts is deferred to the second PR),
4291
+ // so this leaves ZERO eager disk cost while still surfacing the field for the
4292
+ // consumer that arrives next. Non-enumerable so incidental spread/serialize
4293
+ // (e.g. logging) can't trigger a stat storm; memoized per entry so repeated
4294
+ // reads verify once.
4295
+ // #1179: DELIBERATELY NOT migrated to an enumerable wrapper field (unlike the
4296
+ // `touchFile` flags). Enumerable would make an incidental `{...entry}`/serialize
4297
+ // eagerly fire this getter and stat every file — the exact stat storm the
4298
+ // laziness above exists to prevent. It therefore stays on the read-off-original
4299
+ // carriage contract (see `DiagnosticBinding`): every consumer reads `.binding`
4300
+ // off the ORIGINAL Map entry (`clients/dispatch/integration.ts` reads it off
4301
+ // `entry`, never a copy), and the cascade's TTL gate reads it only when fresh.
4302
+ for (const [filePath, entry] of all) {
4303
+ const stored = bindingsByPath.get(filePath) ?? [];
4304
+ let memo;
4305
+ Object.defineProperty(entry, "binding", {
4306
+ enumerable: false,
4307
+ configurable: true,
4308
+ get: () => (memo ??= this.mergeBinding(filePath, stored)),
4309
+ });
4310
+ }
3155
4311
  return all;
3156
4312
  }
3157
4313
  /**
@@ -3186,6 +4342,9 @@ export class LSPService {
3186
4342
  if (this.checkDestroyed())
3187
4343
  return;
3188
4344
  this.isDestroyed = true;
4345
+ for (const key of this.typeScriptIdleTimers.keys()) {
4346
+ this.clearTypeScriptIdleTimer(key);
4347
+ }
3189
4348
  // Belt-and-braces: wait for any in-flight spawns so that Guard 1/2 in
3190
4349
  // spawnClient can observe isDestroyed and clean up. Skip on the
3191
4350
  // process-exiting path — the event loop is closing and we must not block.
@@ -3237,12 +4396,69 @@ export class LSPService {
3237
4396
  getStatus() {
3238
4397
  return Array.from(this.state.clients.entries()).map(([key, client]) => {
3239
4398
  const [serverId, root] = key.split(":");
3240
- return { serverId, root, connected: client.isAlive() };
4399
+ return {
4400
+ serverId,
4401
+ root,
4402
+ connected: client.isAlive(),
4403
+ pullFailureHistory: (client.getPullFailureHistory?.() ?? []).map((entry) => ({
4404
+ ...entry,
4405
+ message: entry.message.slice(0, 200),
4406
+ })),
4407
+ };
3241
4408
  });
3242
4409
  }
4410
+ /**
4411
+ * #1142 windowed-rate breaker (composes with the {@link runtimeExitCounts}
4412
+ * fast path — see that field and {@link RUNTIME_EXIT_WINDOW_MS}).
4413
+ *
4414
+ * Records one non-intentional death and returns true iff this death pushes
4415
+ * the rolling window to {@link RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths, i.e.
4416
+ * the breaker should give up now. The caller has ALREADY excluded intentional
4417
+ * teardowns (shutdown()-driven restarts, #743 eviction, session resets,
4418
+ * generation handoffs) — only genuine crash-respawns reach here.
4419
+ *
4420
+ * Sleep-gap / long-healthy-run guard: a death whose measured lifetime exceeds
4421
+ * {@link RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS} (or is unknown) is NOT
4422
+ * recorded — a lifetime spanning a Modern-Standby suspend, or a genuinely
4423
+ * long run that crashed once, is not churn. See that constant for the
4424
+ * per-KEY structural defense this backs up.
4425
+ *
4426
+ * Bounded on both axes (defect shape 9): prune entries outside the window,
4427
+ * then hard-cap the retained array at the trip count (drop-oldest — a rate
4428
+ * trip needs only the in-window count, so earliest-evidence order is
4429
+ * irrelevant here).
4430
+ */
4431
+ recordRuntimeExitWindow(key, exitedAt, uptimeMs) {
4432
+ if (exitedAt == null ||
4433
+ uptimeMs == null ||
4434
+ uptimeMs > RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS) {
4435
+ return false;
4436
+ }
4437
+ const deaths = this.runtimeExitWindow.get(key) ?? [];
4438
+ deaths.push(exitedAt);
4439
+ // Roll the window around the most recent death. `exitedAt` is the client's
4440
+ // own recorded death time (#1127), which can arrive out of DETECTION order
4441
+ // — a death detected hours later still carries its true, older timestamp —
4442
+ // so anchor on the max, not on push order.
4443
+ const windowRef = Math.max(...deaths);
4444
+ const windowStart = windowRef - RUNTIME_EXIT_WINDOW_MS;
4445
+ let pruned = deaths.filter((t) => t >= windowStart);
4446
+ if (pruned.length > RUNTIME_EXIT_WINDOW_TRIP_COUNT) {
4447
+ pruned = pruned.slice(pruned.length - RUNTIME_EXIT_WINDOW_TRIP_COUNT);
4448
+ }
4449
+ this.runtimeExitWindow.set(key, pruned);
4450
+ return pruned.length >= RUNTIME_EXIT_WINDOW_TRIP_COUNT;
4451
+ }
3243
4452
  /**
3244
4453
  * Read-only circuit-breaker status, including server/root pairs that have no
3245
4454
  * live client and would therefore be absent from getStatus().
4455
+ *
4456
+ * #1127: `failureCounts` (spawn/init failures) and `runtimeExitCounts`
4457
+ * (early post-init runtime exits) are two INDEPENDENT streams feeding one
4458
+ * circuit — either can trip its own cooldown/permanent-disable on its own
4459
+ * threshold, without the other. `failures` below is their SUM, purely for
4460
+ * display: it is the total churn behind whatever cooldown/permanent state
4461
+ * is showing, not a claim that both streams are at that count.
3246
4462
  */
3247
4463
  getBrokenStatus() {
3248
4464
  const keys = new Set([
@@ -3254,7 +4470,16 @@ export class LSPService {
3254
4470
  return {
3255
4471
  serverId: separator >= 0 ? key.slice(0, separator) : key,
3256
4472
  root: separator >= 0 ? key.slice(separator + 1) : "",
3257
- failures: this.failureCounts.get(key) ?? 0,
4473
+ // #1127/#1142: spawn/init failures, consecutive early post-init
4474
+ // exits, and the windowed-rate stream all feed the same breaker
4475
+ // (see `runtimeExitCounts`/`runtimeExitWindow` docs). Sum spawn/init
4476
+ // failures with the LARGER of the two runtime-exit streams — a
4477
+ // single <60s death feeds BOTH runtimeExitCounts and the window, so
4478
+ // `max` avoids double-counting them, while still surfacing a
4479
+ // windowed-only trip (whose runtimeExitCounts may be 0) instead of
4480
+ // misreporting `failures: 0` behind a permanent-disable.
4481
+ failures: (this.failureCounts.get(key) ?? 0) +
4482
+ Math.max(this.runtimeExitCounts.get(key) ?? 0, this.runtimeExitWindow.get(key)?.length ?? 0),
3258
4483
  permanentlyBroken: this.permanentlyBroken.has(key),
3259
4484
  cooldownUntil: this.state.broken.get(key) ?? 0,
3260
4485
  };