@herbertgao/pi-extensions 2026.9.0 → 2026.9.2

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/node_modules/@czottmann/pi-automode/CHANGELOG.md +13 -1
  2. package/node_modules/@czottmann/pi-automode/README.md +20 -0
  3. package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +1 -1
  4. package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +2 -1
  5. package/node_modules/@czottmann/pi-automode/docs/defaults.md +3 -2
  6. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +38 -2
  7. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +5 -3
  8. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +11 -3
  9. package/node_modules/@czottmann/pi-automode/package.json +1 -1
  10. package/node_modules/@narumitw/pi-btw/README.md +57 -141
  11. package/node_modules/@narumitw/pi-btw/dist/index.ts +466 -41
  12. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  13. package/node_modules/@narumitw/pi-btw/docs/workflows.md +74 -0
  14. package/node_modules/@narumitw/pi-btw/package.json +2 -1
  15. package/node_modules/@narumitw/pi-btw/src/btw.ts +15 -1
  16. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +32 -2
  17. package/node_modules/@narumitw/pi-btw/src/keybindings.ts +330 -0
  18. package/node_modules/@narumitw/pi-btw/src/menu.ts +136 -19
  19. package/node_modules/@narumitw/pi-btw/src/settings.ts +32 -0
  20. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +57 -5
  21. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +52 -30
  22. package/node_modules/@pi-plugins/fast-mode/README.md +5 -3
  23. package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts.map +1 -1
  24. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +20 -55
  25. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  26. package/node_modules/@pi-plugins/fast-mode/package.json +1 -1
  27. package/node_modules/pi-lens/CHANGELOG.md +1072 -0
  28. package/node_modules/pi-lens/README.md +3 -0
  29. package/node_modules/pi-lens/config/biome/core.jsonc +11 -2
  30. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +13 -1
  31. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +2 -9
  32. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +927 -80
  33. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +1 -1
  34. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +13 -4
  35. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +123 -3
  36. package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +60 -5
  37. package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +2 -9
  38. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +2 -0
  39. package/node_modules/pi-lens/dist/clients/bash-file-access.js +133 -3
  40. package/node_modules/pi-lens/dist/clients/biome-client.js +9 -2
  41. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +14 -6
  42. package/node_modules/pi-lens/dist/clients/bootstrap.js +509 -73
  43. package/node_modules/pi-lens/dist/clients/bounded-cache.js +152 -12
  44. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +1 -1
  45. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +59 -9
  46. package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +113 -0
  47. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +3 -11
  48. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +31 -4
  49. package/node_modules/pi-lens/dist/clients/cache-manager.js +105 -6
  50. package/node_modules/pi-lens/dist/clients/cache-observability.js +40 -19
  51. package/node_modules/pi-lens/dist/clients/cargo-manifest.js +422 -0
  52. package/node_modules/pi-lens/dist/clients/cascade-logger.js +2 -9
  53. package/node_modules/pi-lens/dist/clients/child-unref.js +1 -19
  54. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +13 -3
  55. package/node_modules/pi-lens/dist/clients/complexity-client.js +16 -5
  56. package/node_modules/pi-lens/dist/clients/config-core/deny.js +221 -0
  57. package/node_modules/pi-lens/dist/clients/config-core/index.js +50 -0
  58. package/node_modules/pi-lens/dist/clients/config-core/merge.js +357 -0
  59. package/node_modules/pi-lens/dist/clients/config-core/normalize.js +340 -0
  60. package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +248 -0
  61. package/node_modules/pi-lens/dist/clients/config-core/provenance.js +164 -0
  62. package/node_modules/pi-lens/dist/clients/config-core/records.js +218 -0
  63. package/node_modules/pi-lens/dist/clients/config-core/resolve.js +125 -0
  64. package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +78 -0
  65. package/node_modules/pi-lens/dist/clients/config-core/schema.js +165 -0
  66. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +281 -0
  67. package/node_modules/pi-lens/dist/clients/config-locations.js +160 -0
  68. package/node_modules/pi-lens/dist/clients/config-resolve.js +789 -0
  69. package/node_modules/pi-lens/dist/clients/config-schema.js +234 -0
  70. package/node_modules/pi-lens/dist/clients/config-warn.js +407 -0
  71. package/node_modules/pi-lens/dist/clients/dead-code-client.js +19 -14
  72. package/node_modules/pi-lens/dist/clients/dead-code-logger.js +2 -6
  73. package/node_modules/pi-lens/dist/clients/deadline-utils.js +178 -0
  74. package/node_modules/pi-lens/dist/clients/debug-handles.js +2 -2
  75. package/node_modules/pi-lens/dist/clients/debug-heap.js +3 -3
  76. package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +106 -0
  77. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +113 -7
  78. package/node_modules/pi-lens/dist/clients/dependency-checker.js +8 -19
  79. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +1 -1
  80. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +12 -9
  81. package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +15 -5
  82. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +0 -4
  83. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +162 -44
  84. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +97 -0
  85. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +27 -8
  86. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +0 -6
  87. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +1 -1
  88. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +0 -3
  89. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-complexity.js +2 -2
  90. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-fan-out.js +1 -1
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -1
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +383 -27
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -1
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +4 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -1
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -1
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -1
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -1
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -1
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -1
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -1
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +1 -3
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -1
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -1
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +1 -2
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -1
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -1
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -1
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -1
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -1
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -1
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +14 -18
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -1
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -1
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +6 -3
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +7 -4
  122. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -1
  123. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +1 -2
  124. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +1 -3
  125. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +1 -2
  126. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -1
  127. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +4 -3
  128. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -1
  129. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  130. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -1
  131. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -1
  132. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -1
  133. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -1
  134. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -1
  135. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  136. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -1
  137. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  138. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -67
  139. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +194 -80
  140. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +84 -15
  141. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils.js +0 -21
  142. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -1
  143. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +2 -20
  144. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +1 -1
  145. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -1
  146. package/node_modules/pi-lens/dist/clients/dispatch/utils/format-utils.js +2 -2
  147. package/node_modules/pi-lens/dist/clients/disposition-logger.js +3 -10
  148. package/node_modules/pi-lens/dist/clients/effective-config.js +403 -0
  149. package/node_modules/pi-lens/dist/clients/error-class.js +23 -0
  150. package/node_modules/pi-lens/dist/clients/event-loop-hold.js +274 -0
  151. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +2 -2
  152. package/node_modules/pi-lens/dist/clients/extension-log.js +2 -2
  153. package/node_modules/pi-lens/dist/clients/feature-hints.js +2 -1
  154. package/node_modules/pi-lens/dist/clients/file-kinds.js +1 -38
  155. package/node_modules/pi-lens/dist/clients/file-time.js +7 -2
  156. package/node_modules/pi-lens/dist/clients/file-utils.js +117 -10
  157. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +50 -13
  158. package/node_modules/pi-lens/dist/clients/format-service.js +6 -2
  159. package/node_modules/pi-lens/dist/clients/formatters.js +385 -104
  160. package/node_modules/pi-lens/dist/clients/freshness-cadence.js +17 -0
  161. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +116 -22
  162. package/node_modules/pi-lens/dist/clients/generation-guard.js +4 -9
  163. package/node_modules/pi-lens/dist/clients/git-guard.js +6 -1
  164. package/node_modules/pi-lens/dist/clients/go-client.js +39 -0
  165. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +252 -0
  166. package/node_modules/pi-lens/dist/clients/grammar-source.js +3 -3
  167. package/node_modules/pi-lens/dist/clients/hashline-anchor.js +424 -0
  168. package/node_modules/pi-lens/dist/clients/hook-budgets.js +76 -0
  169. package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +5 -2
  170. package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
  171. package/node_modules/pi-lens/dist/clients/installer/index.js +289 -59
  172. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +84 -18
  173. package/node_modules/pi-lens/dist/clients/instance-reaper.js +110 -236
  174. package/node_modules/pi-lens/dist/clients/instance-registry.js +6 -3
  175. package/node_modules/pi-lens/dist/clients/language-profile.js +0 -9
  176. package/node_modules/pi-lens/dist/clients/language-registry.js +599 -0
  177. package/node_modules/pi-lens/dist/clients/latency-logger.js +165 -9
  178. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +34 -1
  179. package/node_modules/pi-lens/dist/clients/lens-config.js +160 -30
  180. package/node_modules/pi-lens/dist/clients/lens-engine.js +33 -41
  181. package/node_modules/pi-lens/dist/clients/lens-events.js +1 -1
  182. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +77 -6
  183. package/node_modules/pi-lens/dist/clients/lens-map.js +1 -1
  184. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -38
  185. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +3 -1
  186. package/node_modules/pi-lens/dist/clients/lsp/client.js +172 -33
  187. package/node_modules/pi-lens/dist/clients/lsp/config.js +484 -158
  188. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +1 -1
  189. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +1 -1
  190. package/node_modules/pi-lens/dist/clients/lsp/edits.js +18 -5
  191. package/node_modules/pi-lens/dist/clients/lsp/index.js +892 -96
  192. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +1 -1
  193. package/node_modules/pi-lens/dist/clients/lsp/language.js +18 -177
  194. package/node_modules/pi-lens/dist/clients/lsp/launch.js +2 -1
  195. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  196. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +5 -18
  197. package/node_modules/pi-lens/dist/clients/lsp/server.js +284 -147
  198. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +116 -23
  199. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +2 -7
  200. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +0 -1
  201. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +14 -7
  202. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +6 -0
  203. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +2 -1
  204. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +1 -1
  205. package/node_modules/pi-lens/dist/clients/lsp-document-symbols.js +1 -1
  206. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +163 -21
  207. package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +36 -0
  208. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +33 -4
  209. package/node_modules/pi-lens/dist/clients/mcp/session.js +5 -16
  210. package/node_modules/pi-lens/dist/clients/memory-sampler.js +8 -3
  211. package/node_modules/pi-lens/dist/clients/metrics-history.js +28 -107
  212. package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +3 -5
  213. package/node_modules/pi-lens/dist/clients/module-report.js +21 -46
  214. package/node_modules/pi-lens/dist/clients/mutating-tool.js +651 -0
  215. package/node_modules/pi-lens/dist/clients/mutation-attribution.js +368 -0
  216. package/node_modules/pi-lens/dist/clients/mutation-bridge.js +240 -0
  217. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +247 -24
  218. package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +101 -0
  219. package/node_modules/pi-lens/dist/clients/observed-mutation.js +1215 -0
  220. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +70 -28
  221. package/node_modules/pi-lens/dist/clients/opengrep-config.js +5 -1
  222. package/node_modules/pi-lens/dist/clients/package-manager.js +195 -26
  223. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +359 -80
  224. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +23 -7
  225. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +21 -2
  226. package/node_modules/pi-lens/dist/clients/path-utils.js +451 -15
  227. package/node_modules/pi-lens/dist/clients/performance-report.js +2 -2
  228. package/node_modules/pi-lens/dist/clients/persist-debounce.js +8 -1
  229. package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +114 -0
  230. package/node_modules/pi-lens/dist/clients/pipeline.js +79 -18
  231. package/node_modules/pi-lens/dist/clients/probe-home-state.js +228 -0
  232. package/node_modules/pi-lens/dist/clients/process-bridge.js +66 -0
  233. package/node_modules/pi-lens/dist/clients/process-snapshot.js +68 -0
  234. package/node_modules/pi-lens/dist/clients/project-changes.js +1 -1
  235. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +1 -1
  236. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  237. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/jscpd.js +1 -1
  238. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/knip.js +1 -1
  239. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +1 -1
  240. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/opengrep.js +1 -1
  241. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +28 -4
  242. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +51 -20
  243. package/node_modules/pi-lens/dist/clients/project-lens-config.js +438 -122
  244. package/node_modules/pi-lens/dist/clients/project-scan-policy.js +3 -51
  245. package/node_modules/pi-lens/dist/clients/project-snapshot.js +52 -24
  246. package/node_modules/pi-lens/dist/clients/python-environment.js +217 -0
  247. package/node_modules/pi-lens/dist/clients/python-provenance.js +639 -0
  248. package/node_modules/pi-lens/dist/clients/quiet-window.js +6 -1
  249. package/node_modules/pi-lens/dist/clients/read-bridge.js +13 -21
  250. package/node_modules/pi-lens/dist/clients/read-expansion.js +9 -46
  251. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +16 -5
  252. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +364 -212
  253. package/node_modules/pi-lens/dist/clients/read-guard.js +26 -2
  254. package/node_modules/pi-lens/dist/clients/recent-touches.js +2 -2
  255. package/node_modules/pi-lens/dist/clients/resource-sampler.js +195 -96
  256. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +155 -114
  257. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +1 -1
  258. package/node_modules/pi-lens/dist/clients/review-graph/service.js +55 -3
  259. package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  260. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +10 -74
  261. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +9 -3
  262. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +19 -3
  263. package/node_modules/pi-lens/dist/clients/runtime-context.js +49 -16
  264. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +25 -6
  265. package/node_modules/pi-lens/dist/clients/runtime-session.js +184 -32
  266. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +179 -36
  267. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +797 -243
  268. package/node_modules/pi-lens/dist/clients/runtime-turn.js +1028 -53
  269. package/node_modules/pi-lens/dist/clients/rust-client.js +31 -0
  270. package/node_modules/pi-lens/dist/clients/safe-spawn.js +33 -10
  271. package/node_modules/pi-lens/dist/clients/sanitize.js +7 -12
  272. package/node_modules/pi-lens/dist/clients/scan-utils.js +1 -56
  273. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +1 -3
  274. package/node_modules/pi-lens/dist/clients/security-scan-client.js +3 -0
  275. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +1 -1
  276. package/node_modules/pi-lens/dist/clients/session-start-observability.js +79 -0
  277. package/node_modules/pi-lens/dist/clients/session-summary.js +0 -24
  278. package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +12 -3
  279. package/node_modules/pi-lens/dist/clients/sgconfig.js +7 -6
  280. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +1 -1
  281. package/node_modules/pi-lens/dist/clients/skills-resolver.js +105 -0
  282. package/node_modules/pi-lens/dist/clients/smells-rollup.js +4 -4
  283. package/node_modules/pi-lens/dist/clients/startup-scan.js +2 -10
  284. package/node_modules/pi-lens/dist/clients/string-utils.js +13 -0
  285. package/node_modules/pi-lens/dist/clients/subagent-mode.js +17 -4
  286. package/node_modules/pi-lens/dist/clients/test-runner-client.js +176 -39
  287. package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +244 -0
  288. package/node_modules/pi-lens/dist/clients/tool-definition.js +41 -1
  289. package/node_modules/pi-lens/dist/clients/tool-policy.js +92 -22
  290. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +40 -40
  291. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +87 -100
  292. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +2 -2
  293. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +81 -1
  294. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +51 -46
  295. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  296. package/node_modules/pi-lens/dist/clients/tui-fit.js +0 -4
  297. package/node_modules/pi-lens/dist/clients/typos-config.js +5 -0
  298. package/node_modules/pi-lens/dist/clients/user-notify.js +6 -6
  299. package/node_modules/pi-lens/dist/clients/widget-state.js +191 -26
  300. package/node_modules/pi-lens/dist/clients/word-index-logger.js +2 -9
  301. package/node_modules/pi-lens/dist/clients/word-index-store.js +23 -12
  302. package/node_modules/pi-lens/dist/clients/word-index.js +252 -47
  303. package/node_modules/pi-lens/dist/clients/workspace-topology.js +8 -1
  304. package/node_modules/pi-lens/dist/clients/zizmor-config.js +4 -1
  305. package/node_modules/pi-lens/dist/index.js +65899 -55340
  306. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +3 -1
  307. package/node_modules/pi-lens/dist/mcp/build-staleness.js +1 -1
  308. package/node_modules/pi-lens/dist/mcp/server.js +127 -5
  309. package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +583 -0
  310. package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +129 -0
  311. package/node_modules/pi-lens/dist/tools/effective-config.js +89 -0
  312. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +96 -25
  313. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +104 -86
  314. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +37 -16
  315. package/node_modules/pi-lens/dist/tools/lsp-structured-output.js +3 -3
  316. package/node_modules/pi-lens/dist/tools/render-compact.js +1 -1
  317. package/node_modules/pi-lens/dist/tools/shared.js +0 -1
  318. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -2
  319. package/node_modules/pi-lens/docs/agent-guide.md +64 -1
  320. package/node_modules/pi-lens/docs/configuration.md +222 -0
  321. package/node_modules/pi-lens/docs/dependencies.md +3 -3
  322. package/node_modules/pi-lens/docs/features.md +44 -5
  323. package/node_modules/pi-lens/docs/globalconfig.md +20 -0
  324. package/node_modules/pi-lens/docs/language-coverage.md +2 -2
  325. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +9 -1
  326. package/node_modules/pi-lens/docs/pi-lens-fixer.md +25 -0
  327. package/node_modules/pi-lens/docs/pi-lens-investigator.md +25 -0
  328. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +33 -0
  329. package/node_modules/pi-lens/docs/pi-lens-subagent.md +49 -0
  330. package/node_modules/pi-lens/docs/pi-lens-warden.md +55 -0
  331. package/node_modules/pi-lens/docs/public-api-stability.md +359 -0
  332. package/node_modules/pi-lens/docs/release-qa-baseline.md +190 -0
  333. package/node_modules/pi-lens/docs/servercapabilities.md +7 -3
  334. package/node_modules/pi-lens/docs/subagent-compat.md +110 -30
  335. package/node_modules/pi-lens/docs/tree-sitter_rules_catalog.md +2 -2
  336. package/node_modules/pi-lens/docs/word-index.md +1 -1
  337. package/node_modules/pi-lens/package.json +18 -35
  338. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  339. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  340. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  341. package/node_modules/pi-lens/rules/typos/_typos.toml +4 -2
  342. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +192 -1
  343. package/node_modules/pi-lens/scripts/install-selftest.mjs +58 -2
  344. package/node_modules/pi-lens/scripts/lib/skills-predicate.mjs +129 -0
  345. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +1 -1
  346. package/node_modules/pi-lens/scripts/rpc-load-check.mjs +3 -1
  347. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +4 -2
  348. package/node_modules/pi-web-access/CHANGELOG.md +28 -0
  349. package/node_modules/pi-web-access/README.md +62 -15
  350. package/node_modules/pi-web-access/curator-page.ts +3 -1
  351. package/node_modules/pi-web-access/curator-server.ts +5 -1
  352. package/node_modules/pi-web-access/gemini-search.ts +8 -4
  353. package/node_modules/pi-web-access/github-extract.ts +242 -1
  354. package/node_modules/pi-web-access/index.ts +118 -64
  355. package/node_modules/pi-web-access/mistral-search.ts +281 -0
  356. package/node_modules/pi-web-access/package.json +2 -2
  357. package/node_modules/pi-web-access/perplexity.ts +14 -1
  358. package/node_modules/pi-web-access/utils.ts +23 -6
  359. package/node_modules/pi-web-access/xai-search.ts +96 -33
  360. package/package.json +6 -6
@@ -1,5 +1,6 @@
1
+ import * as fs from "node:fs";
1
2
  import * as path from "node:path";
2
- import { appendActionableWarningsHistory, buildActionableWarningsReport, formatActionableWarningsAdvisory, writeActionableWarningsReport, } from "./actionable-warnings.js";
3
+ import { buildActionableWarningsReport, formatActionableWarningsAdvisory, publishActionableWarningsReport, writeDeferredActionableWarningsReport, } from "./actionable-warnings.js";
3
4
  import { logActionableWarningsEvent } from "./actionable-warnings-logger.js";
4
5
  import { appendCodeQualityWarningsHistory, buildCodeQualityWarningsReport, formatCodeQualityWarningsAdvisory, writeCodeQualityWarningsReport, } from "./code-quality-warnings.js";
5
6
  import { clearGitGuardTestFailure, mergeGitGuardTestFailure, writeGitGuardRecord, } from "./git-guard.js";
@@ -29,6 +30,7 @@ import { emitLensTurnFindings } from "./lens-events.js";
29
30
  import { RUNTIME_CONFIG } from "./runtime-config.js";
30
31
  import { isSubagentSession } from "./subagent-mode.js";
31
32
  import { formatRunDurationMs } from "./run-duration.js";
33
+ import { isExcludedTestTarget, isRunnerErrorResult, RUNNERS, } from "./test-runner-client.js";
32
34
  import { MAX_ADVISORY_AFFECTED_FILES, gateFindingsByPathFreshness, snapshotAdvisoryProvenance, } from "./advisory-provenance.js";
33
35
  import { DEPENDENCY_DRIFT_MAX_DELIVERIES, sweepInlineBlockerFreshness, } from "./blocker-freshness.js";
34
36
  import { sweepInlineBlockerPastEof } from "./blocker-past-eof.js";
@@ -40,11 +42,239 @@ import { drainPendingRunnerFindings, dropStaleRunnerFindings, pendingRunnerFindi
40
42
  // #1631 review V2: moved to its own leaf module so a low-level store
41
43
  // (widget-state.ts) can use the marker without importing this orchestrator —
42
44
  // see clients/stale-marker.ts's doc comment.
43
- import { incrementDegradationCount } from "./degradation-ledger.js";
45
+ import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
46
+ import { mapWithConcurrency } from "./map-with-concurrency.js";
47
+ import { getAmbientAbortSignal } from "./safe-spawn.js";
48
+ import { emitBounded } from "./bounded-telemetry.js";
44
49
  import { degradeDemotedFindingBody, formatDeliveryCapNote, formatRetirementNote, } from "./demoted-finding-render.js";
45
50
  import { STALE_LINE_MARKER } from "./stale-marker.js";
46
51
  import { getActiveSessionId } from "./session-lifecycle.js";
47
- import { getWidgetBlockingFilesForSweep, markWidgetFileBlockersStale, recordRunner, } from "./widget-state.js";
52
+ import { drainRenderedDependencyDriftFilePaths, getWidgetBlockingFilesForSweep, incrementWidgetDependencyDriftDelivery, markWidgetFileBlockersStale, recordRunner, retireWidgetDependencyDriftBlockers, } from "./widget-state.js";
53
+ /** Maximum detailed notify-stall coverage-gap rows emitted in one turn. */
54
+ const LATE_AUX_COVERAGE_GAP_DETAIL_CAP_PER_TURN = 20;
55
+ /**
56
+ * #2504 — bounds on the per-turn test-runner fan-out.
57
+ *
58
+ * The reported turn fired 59 `vitest.cmd` spawns at once from a bare
59
+ * `Promise.allSettled(targets.map(...))`. Each spawn carries its OWN 60 s
60
+ * timeout (`test-runner-client.ts`), which bounds one target and says nothing
61
+ * about the batch: 59 of them starved the event loop for the whole turn
62
+ * (`cpuCoverageRatio 0.56`, 20 orphan-backstop escalations inside the storm).
63
+ *
64
+ * Three separate bounds, because they fail differently:
65
+ * - CONCURRENCY caps how much CPU the batch can hold at one instant;
66
+ * - TARGET COUNT caps how much work one turn may enqueue at all;
67
+ * - the WALL BUDGET caps how long the batch may keep spawning, and is raced
68
+ * against the ambient abort signal so a cancelled turn stops dispatching
69
+ * immediately rather than at the next natural boundary.
70
+ *
71
+ * #2522: the batch budget shipped at 90 s in #2509 — well over the turn
72
+ * itself, so a slow/hung batch could still hold up the agent for most of a
73
+ * turn before the bound even engaged. Reconciled down to 20 s (one constant,
74
+ * not a second budget alongside it): well under the turn, and still an order
75
+ * of magnitude below the PER-TARGET 60 s cap in `test-runner-client.ts`, so
76
+ * the batch bound is the one that actually fires first for a stuck target.
77
+ *
78
+ * #2522 review round 2: lowering the budget on its own made things WORSE,
79
+ * because the "deferred to the next turn" this comment used to claim did not
80
+ * exist. `runTestTargetsBounded` returned a skipped COUNT and threw the
81
+ * identity of the unrun targets away, so a target slower than the budget
82
+ * produced no result, no cache record and no output — every turn, forever —
83
+ * while a PARTIAL batch fell through to the clean branch and was reported as
84
+ * "all tests passed". The deferral is now a real mechanism:
85
+ *
86
+ * - the batch owns an `AbortController` that is handed to each target's spawn
87
+ * (`TestRunRequest.signal` → `safeSpawnAsync`), so reaching the bound KILLS
88
+ * what is in flight instead of leaving it to burn its own 60 s timeout and
89
+ * then mutate a results array the caller has already consumed;
90
+ * - the killed set plus the never-dispatched set is returned BY IDENTITY as
91
+ * `deferred`, persisted on the `test-runner-findings` record as
92
+ * `deferredTargets`, and dispatched FIRST on the next turn — ahead of
93
+ * failed-first/related/self — under the same `TEST_RUNNER_MAX_TARGETS` cap;
94
+ * - a batch whose `deferred` set is non-empty can never take the clean
95
+ * branch, so an unfinished run is never reported as a green one and never
96
+ * relaxes the `--lens-guard` commit gate.
97
+ */
98
+ export const TEST_RUNNER_BATCH_CONCURRENCY = 4;
99
+ export const TEST_RUNNER_MAX_TARGETS = 12;
100
+ export const TEST_RUNNER_BATCH_BUDGET_MS = 20_000;
101
+ /**
102
+ * How many consecutive turn-end batches a target may be cut out of before it
103
+ * is retired from turn-end selection altogether.
104
+ *
105
+ * Without this the deferral is a LIVELOCK, and not hypothetically: this
106
+ * repo's own `tests/index-integration.test.ts` — which turn-end resolves
107
+ * through the `related` strategy whenever `clients/runtime-turn.ts` is
108
+ * edited — takes 26.4 s, MORE than the entire 20 s batch budget on its own.
109
+ * Deferring it puts it FIRST in the next batch, where it is cut again at
110
+ * 20 s, and again, every turn, forever. A target that cannot fit the budget
111
+ * is not a scheduling problem retrying can solve; it is reported once per
112
+ * occurrence and dropped, so the turn stops paying 20 s for it.
113
+ */
114
+ export const TEST_RUNNER_MAX_DEFERRALS = 2;
115
+ /**
116
+ * Ceiling on how many entries either persisted target list may carry.
117
+ *
118
+ * #2522 review round 4, I1: a write may no longer destroy another session's
119
+ * entries, so nothing in the write path prunes them any more — each session
120
+ * that ever cut or retired a target in this project leaves its rows behind, on
121
+ * a record read at every single turn_end. The bound is applied at the one
122
+ * writer, on the whole list, and sheds FOREIGN rows first (the writer orders
123
+ * this session's entries last), so it can never evict the entries this turn
124
+ * depends on. Generous on purpose: it is a backstop against unbounded growth,
125
+ * not a scheduling policy.
126
+ */
127
+ export const TEST_RUNNER_MAX_PERSISTED_TARGETS = 64;
128
+ /**
129
+ * Union two deferral sets by target identity, keeping the HIGHER attempt count
130
+ * (#2522 review round 3, F3). Two overlapping batches can both be cut on the
131
+ * same target; taking the lower count would let a target trade an attempt for
132
+ * every overlap and never converge on `TEST_RUNNER_MAX_DEFERRALS`.
133
+ *
134
+ * Identity is (session, path), not path alone (#2522 review round 4, I1): two
135
+ * sessions can each owe a run of the same file, and collapsing those into one
136
+ * row makes the surviving row's `sessionId` decide whose deferral is honoured
137
+ * and whose is silently dropped. The path half is keyed through
138
+ * `normalizeMapKey` so `/`- and `\`-separated spellings are one entry
139
+ * (AGENTS.md cross-form-path screen).
140
+ */
141
+ function deferralEntryKey(entry) {
142
+ return `${entry.sessionId ?? ""}\u0000${normalizeMapKey(path.resolve(entry.testFile))}`;
143
+ }
144
+ function mergeDeferredTargets(existing, incoming) {
145
+ const byKey = new Map();
146
+ for (const entry of [...existing, ...incoming]) {
147
+ const key = deferralEntryKey(entry);
148
+ const prior = byKey.get(key);
149
+ if (prior && (prior.attempts ?? 0) >= (entry.attempts ?? 0))
150
+ continue;
151
+ byKey.set(key, entry);
152
+ }
153
+ return [...byKey.values()];
154
+ }
155
+ /**
156
+ * Run `run` over `targets` with a concurrency cap, a batch-wide wall budget
157
+ * and an abort-signal race. Reuses `mapWithConcurrency` (the repo's existing
158
+ * worker-pool shape) rather than hand-rolling a second pool.
159
+ *
160
+ * Both bounds are enforced twice on purpose: cooperatively, before each
161
+ * dispatch, so no NEW work starts past the bound; and as a real race against
162
+ * the pool, so an in-flight target cannot hold the batch past its budget.
163
+ * Whatever has settled by then is returned — a partial batch of real results
164
+ * is strictly better than none, and the caller reports the shortfall.
165
+ */
166
+ export async function runTestTargetsBounded(args) {
167
+ const results = [];
168
+ if (args.targets.length === 0)
169
+ return { results, deferred: [] };
170
+ let stopReason;
171
+ // One controller for the whole batch. Aborting it tree-kills every child the
172
+ // runner has in flight (`safeSpawnAsync` honours the signal it is handed),
173
+ // which is what turns "the bound fired" into "the work actually stopped".
174
+ const batchAbort = new AbortController();
175
+ // Latched at the moment this call returns. Nothing may push into `results`
176
+ // after that: the caller has already consumed the array, and a late push
177
+ // would silently change a batch it has finished reasoning about.
178
+ let closed = false;
179
+ /**
180
+ * #2522 review round 4, I4: "was this target CUT, or did it FINISH?" is a
181
+ * semantic question, and this set is the answer — stamped by the `.then`
182
+ * attached to `run`'s promise AT DISPATCH, which is that promise's FIRST
183
+ * continuation. Nothing but a microtask can run between the promise settling
184
+ * and this stamp, and every path that closes the batch (`setTimeout`, the
185
+ * ambient signal's `abort` event, the pool settling after every mapper has
186
+ * returned) reaches `finish()` from a macrotask or later. So `closed` read
187
+ * here is exactly "the batch was already cut when this value existed".
188
+ *
189
+ * Round 3 read the same flag from the mapper's own `await` continuation,
190
+ * which is several continuations downstream of the settle — a macrotask CAN
191
+ * interleave there — and papered over the resulting misclassification with a
192
+ * `setImmediate` hop plus a `latchedOut` parking array. Both are gone: the
193
+ * hop made the answer depend on queue ordering, which is the thing that kept
194
+ * producing findings (a spawn aborted BEFORE it started resolves
195
+ * synchronously, so the hop folded it back in as a completed run — round 4
196
+ * P3c).
197
+ */
198
+ const settledBeforeClose = new Set();
199
+ const budgetMs = Math.max(0, args.budgetMs);
200
+ const deadline = Date.now() + budgetMs;
201
+ const shouldStop = () => {
202
+ if (stopReason !== undefined)
203
+ return true;
204
+ if (args.signal?.aborted) {
205
+ stopReason = "abort";
206
+ return true;
207
+ }
208
+ if (Date.now() >= deadline) {
209
+ stopReason = "budget";
210
+ return true;
211
+ }
212
+ return false;
213
+ };
214
+ const pool = mapWithConcurrency(args.targets.map((target, index) => ({ target, index })), Math.max(1, args.concurrency), async ({ target, index }) => {
215
+ if (shouldStop())
216
+ return;
217
+ const running = args.run(target, batchAbort.signal);
218
+ // Attached HERE, at dispatch, so it is the promise's first
219
+ // continuation — see `settledBeforeClose`. A value that only exists
220
+ // because the batch was cut (a killed spawn's runner error, or
221
+ // `safeSpawnAsync`'s synchronous "Spawn aborted before start") settles
222
+ // strictly after the close and is therefore never recorded, so the
223
+ // caller defers that target instead of reading it as a completed run.
224
+ const stamp = running.then((value) => {
225
+ if (closed)
226
+ return;
227
+ settledBeforeClose.add(index);
228
+ results.push({ status: "fulfilled", value });
229
+ }, (reason) => {
230
+ if (closed)
231
+ return;
232
+ settledBeforeClose.add(index);
233
+ results.push({ status: "rejected", reason });
234
+ });
235
+ // The stamp swallows both settlements, so this only keeps the pool's
236
+ // concurrency slot held for as long as the work actually runs.
237
+ await stamp;
238
+ });
239
+ // The pool's mapper swallows every throw from an async `run`; a `run` that throws
240
+ // SYNCHRONOUSLY still rejects the pool and closes the whole batch (everything defers,
241
+ // nothing publishes clean). Production runners are async, so this is fail-safe, not reachable.
242
+ void pool.catch(() => { });
243
+ await new Promise((resolve) => {
244
+ let settled = false;
245
+ const finish = (reason) => {
246
+ if (settled)
247
+ return;
248
+ settled = true;
249
+ if (reason !== undefined)
250
+ stopReason ??= reason;
251
+ // Latch BEFORE resolving, so no continuation scheduled by the kill
252
+ // below can slip a result in behind the caller's back.
253
+ closed = true;
254
+ // Kill whatever is still in flight. Without this the pool ran on past
255
+ // the bound: a stuck vitest spawn kept a core busy to its own 60s
256
+ // timeout, long after the turn had moved on.
257
+ batchAbort.abort();
258
+ clearTimeout(timer);
259
+ args.signal?.removeEventListener("abort", onAbort);
260
+ resolve();
261
+ };
262
+ const onAbort = () => finish("abort");
263
+ const timer = setTimeout(() => finish("budget"), budgetMs);
264
+ // A pending batch timer must never hold the process open.
265
+ timer.unref?.();
266
+ if (args.signal?.aborted)
267
+ finish("abort");
268
+ else
269
+ args.signal?.addEventListener("abort", onAbort, { once: true });
270
+ void pool.then(() => finish(), () => finish());
271
+ });
272
+ return {
273
+ results,
274
+ deferred: args.targets.filter((_, index) => !settledBeforeClose.has(index)),
275
+ stopReason,
276
+ };
277
+ }
48
278
  /**
49
279
  * #1617: turn_end reads gitleaks/govulncheck/trivy straight from their
50
280
  * session-scan caches and formats them into advisory/blocker text — a
@@ -239,7 +469,7 @@ function capTurnEndMessage(content) {
239
469
  return out;
240
470
  }
241
471
  export async function handleTurnEnd(deps) {
242
- const { ctxCwd, getFlag, dbg, runtime, cacheManager, knipClient, deadCodeClients, depChecker, testRunnerClient, owner, resetLSPService, resetFormatService, } = deps;
472
+ const { ctxCwd, getFlag, dbg, runtime, cacheManager, knipClient, deadCodeClients, depChecker, testRunnerClient, sessionId, owner, resetLSPService, resetFormatService, } = deps;
243
473
  // #449 slice 1: piggyback the instance-registry heartbeat on this existing
244
474
  // per-turn touchpoint rather than adding a new timer/interval. Cheap (reads
245
475
  // process.memoryUsage().rss, one read-modify-write of instances.json) and
@@ -261,13 +491,27 @@ export async function handleTurnEnd(deps) {
261
491
  let turnState = cacheManager.readTurnState(cwd);
262
492
  // A live foreign writer owns this worklist. Do not clear or consume another
263
493
  // pi/MCP session's files; a dead/aged owner is safely evicted instead.
264
- const currentOwner = owner ?? {
265
- kind: "pi",
266
- id: runtime.telemetrySessionId,
267
- pid: process.pid,
268
- lastSeen: new Date().toISOString(),
494
+ // #2504: `sessionStartedAt` dates the persisted worklist against THIS
495
+ // session. Without it an ownerless turn-state.json — the resting shape
496
+ // before #2504 — read back as "owned" no matter how old it was.
497
+ const currentOwner = {
498
+ ...(owner ?? {
499
+ kind: "pi",
500
+ id: runtime.telemetrySessionId,
501
+ pid: process.pid,
502
+ lastSeen: new Date().toISOString(),
503
+ }),
504
+ sessionStartedAt: owner?.sessionStartedAt ?? runtime.sessionStartedAt,
269
505
  };
270
506
  const access = cacheManager.getTurnStateAccess(cwd, currentOwner);
507
+ // Captured BEFORE the eviction below rewrites the file: the owner the gate
508
+ // actually judged. This pair is what would have settled #2504 from the
509
+ // debug log alone.
510
+ const gateOwnerLabel = turnState.owner
511
+ ? `${turnState.owner.kind}:${turnState.owner.id}`
512
+ : turnState.sessionId
513
+ ? `legacy:${turnState.sessionId}`
514
+ : "none";
271
515
  const sameProcessPiSessionHandoff = access === "foreign-live" &&
272
516
  currentOwner.kind === "pi" &&
273
517
  turnState.owner?.kind === "pi" &&
@@ -284,6 +528,49 @@ export async function handleTurnEnd(deps) {
284
528
  turnState = cacheManager.readTurnState(cwd);
285
529
  }
286
530
  const files = Object.keys(turnState.files);
531
+ /**
532
+ * #2275: widget-footer sibling of #1950's inline-blocker cap, for the
533
+ * widget store's OWN dependency-drift demotion
534
+ * (`markWidgetFileBlockersStale`, driven by the freshness sweep further
535
+ * down this function) — a completely separate store from
536
+ * `RuntimeCoordinator`'s inline-blocker map, so it needed its own
537
+ * delivery count (`WidgetDiagnostic.staleDeliveryCount`) rather than
538
+ * inheriting one.
539
+ *
540
+ * Review F1: the population is what the footer RENDERED since the last
541
+ * turn end, drained here — not every file that merely holds a demoted
542
+ * row. The footer draws one record per pass (`withBlocking[0]`, its top
543
+ * five entries) and may not be drawn at all, so a per-turn walk of the
544
+ * whole store charged deliveries the agent never received and retired a
545
+ * delivery early. This is the widget-surface analogue of the inline
546
+ * loop's own `pendingDependencyDriftDeliveries` deferral below: both
547
+ * commit a delivery only once the surface has actually served it. Every
548
+ * `deliveryCount` reported to the ledger is therefore a count of RENDERS.
549
+ *
550
+ * Fix-round 3 (#2275 review F1): this drain/charge MUST run before the
551
+ * `files.length === 0` early return below — a read-only turn (no
552
+ * modified files) still repaints the footer and can draw a demoted row,
553
+ * so a cap that only charged deliveries below the early return silently
554
+ * starved on quiet turns: the footer re-rendered the same demoted row
555
+ * every turn while the delivery count never advanced. The drain is a
556
+ * Set.take() plus one map lookup per drained file — cheap enough to run
557
+ * unconditionally on every turn end.
558
+ */
559
+ let widgetDemotedFindingsRetired = 0;
560
+ for (const wPath of drainRenderedDependencyDriftFilePaths()) {
561
+ const deliveryCount = incrementWidgetDependencyDriftDelivery(wPath);
562
+ if (deliveryCount >= DEPENDENCY_DRIFT_MAX_DELIVERIES) {
563
+ const capRetired = retireWidgetDependencyDriftBlockers(wPath);
564
+ if (capRetired) {
565
+ widgetDemotedFindingsRetired += 1;
566
+ incrementDegradationCount({
567
+ kind: "demoted-finding-retired",
568
+ subject: `widget-blocker:${toRunnerDisplayPath(cwd, wPath)}`,
569
+ reason: `capped after ${deliveryCount} deliveries with no re-run; hidden from the pi-lens footer, still listed by lens_diagnostics mode=all — re-run can still confirm`,
570
+ });
571
+ }
572
+ }
573
+ }
287
574
  // R1 (#1443 follow-up): a read-only turn (no files touched) must not take
288
575
  // the fast idle-reset path while a carried cascade run — or one still
289
576
  // settling — is waiting for its delivery opportunity. Falling through to
@@ -347,7 +634,7 @@ export async function handleTurnEnd(deps) {
347
634
  cancelLSPIdleReset();
348
635
  dbg("turn_end: cancelled pending LSP idle reset (active editing)");
349
636
  }
350
- dbg(`turn_end: ${files.length} file(s) modified, cycles: ${turnState.turnCycles}/${turnState.maxCycles}`);
637
+ dbg(`turn_end: ${files.length} file(s) modified, cycles: ${turnState.turnCycles}/${turnState.maxCycles}, access: ${access}, owner: ${gateOwnerLabel}`);
351
638
  if (cacheManager.isMaxCyclesExceeded(cwd)) {
352
639
  dbg("turn_end: max cycles exceeded, clearing state and forcing through");
353
640
  cacheManager.clearTurnState(cwd, currentOwner);
@@ -1457,10 +1744,12 @@ export async function handleTurnEnd(deps) {
1457
1744
  metadata: madgeStats ?? { skipped: true },
1458
1745
  });
1459
1746
  // --- Test runner: fire once per turn after all edits are done ---
1460
- // Runs for each unique test target across modified files; results appear
1461
- // in the next turn's context injection alongside jscpd/madge findings.
1747
+ // Runs for each unique test target across modified files; results remain in
1748
+ // the pull-diagnostics cache and are delivered after the agent settles.
1462
1749
  if (!getFlag("no-tests") && files.length > 0) {
1463
1750
  const seen = new Set();
1751
+ // `strategy` is widened by one value the resolver itself cannot produce:
1752
+ // a target carried over from the previous turn's cut batch (#2522 R2 F1).
1464
1753
  const targets = [];
1465
1754
  // #628: also target the test companions of this turn's cascade neighbors
1466
1755
  // (files that import an edited file) — a neighbor's own tests can break
@@ -1491,10 +1780,266 @@ export async function handleTurnEnd(deps) {
1491
1780
  candidates.push({ display: neighbor.filePath, abs, isNeighbor: true });
1492
1781
  }
1493
1782
  }
1783
+ // #2522 review round 2, F1: the previous turn's unfinished targets go
1784
+ // FIRST — ahead of failed-first/related/self — under the same
1785
+ // TEST_RUNNER_MAX_TARGETS cap, so a batch that keeps getting cut makes
1786
+ // progress instead of re-running whatever this turn happened to touch
1787
+ // and dropping the remainder again. The list is consumed here (written
1788
+ // back empty below) so a target can never be carried forever.
1789
+ const priorTestCache = cacheManager.readCache("test-runner-findings", cwd)?.data;
1790
+ const carriedDeferred = priorTestCache?.deferredTargets ?? [];
1791
+ // #2522 review round 3, F5: a deferral list belongs to the session that
1792
+ // cut it. `runtime.telemetrySessionId` is the same identity the batch
1793
+ // stamps its own entries with below (`firedSessionId`), so this is a
1794
+ // single comparison against one source of truth.
1795
+ const turnSessionId = sessionId ?? runtime.telemetrySessionId;
1796
+ const isThisSession = (entry) => entry.sessionId === turnSessionId;
1797
+ const entryPathKey = (entry) => normalizeMapKey(path.resolve(cwd, entry.testFile));
1798
+ /**
1799
+ * #2522 review round 3, F1: the retirement, carried forward.
1800
+ *
1801
+ * Round 2 announced the retirement and dropped it on the floor, so the
1802
+ * candidate loop below re-resolved the very same file through
1803
+ * `related`/`self` in the SAME turn and ran it anyway with a fresh
1804
+ * (undefined) attempt count. Steady state was a 3-turn cycle that spawned
1805
+ * and cut the slow suite on every turn. The retired set is persisted on
1806
+ * the record instead.
1807
+ *
1808
+ * Round 4, I1: this is a SELECTION filter and nothing more. Round 3 also
1809
+ * wrote the filtered list straight back, so a turn taken by the other
1810
+ * entry point into the same project — `clients/mcp/session.ts` passes no
1811
+ * `sessionId` and falls back to `telemetrySessionId`, while `index.ts`
1812
+ * passes pi's stable id — erased the entries belonging to the other
1813
+ * route. Both lists survive every write now; see `writeTestFindings`.
1814
+ */
1815
+ const retiredCarry = (priorTestCache?.retiredTargets ?? []).filter(isThisSession);
1816
+ const retiredKeys = new Set(retiredCarry.map(entryPathKey));
1817
+ /**
1818
+ * Deferral entries of THIS session that this turn has settled: dispatched
1819
+ * into `targets`, retired at the cap, or dropped as unrunnable. Only
1820
+ * these may leave the persisted list, and only when this turn's own write
1821
+ * is not re-asserting them (a cut target comes straight back with
1822
+ * `attempts + 1`).
1823
+ */
1824
+ const resolvedDeferralKeys = new Set();
1825
+ /**
1826
+ * #2522 review round 4, I5: entries this turn could not fit under
1827
+ * `TEST_RUNNER_MAX_TARGETS`. They were not run, so they keep their attempt
1828
+ * count and stay on the list — round 3 folded the over-cap case in with
1829
+ * "no longer resolves" and dropped them outright, so a carry larger than
1830
+ * the cap lost its tail every turn under a dbg line that said the files
1831
+ * were gone.
1832
+ */
1833
+ const heldDeferred = [];
1834
+ /**
1835
+ * The ONE writer of `test-runner-findings` in this block, so the rules
1836
+ * about what survives a write are stated once instead of at six call sites
1837
+ * that a seventh would silently not join (#2522 review round 3, F1; same
1838
+ * net-count rule as `retireTestFindings` in `runtime-context.ts`).
1839
+ *
1840
+ * Three rules, all of them round-4 invariants:
1841
+ * - I1: the LIVE record is re-read here and merged into, never replaced.
1842
+ * A row belonging to another session is untouchable — this turn may
1843
+ * ignore it, never delete it.
1844
+ * - I2: every write merges. A batch that publishes clean, a batch that
1845
+ * publishes failures and a superseded batch all go through this, so
1846
+ * none of them can overwrite a concurrent batch's cut set. Removal is
1847
+ * narrow by construction: an entry leaves only if it is this session's,
1848
+ * this turn settled it, and this write is not re-asserting it.
1849
+ * - the persisted lists are bounded (`TEST_RUNNER_MAX_PERSISTED_TARGETS`)
1850
+ * with this session's rows ordered LAST, so the bound sheds foreign
1851
+ * rows first and can never evict what this turn just recorded.
1852
+ */
1853
+ const boundPersisted = (entries, label) => {
1854
+ const ordered = [
1855
+ ...entries.filter((entry) => !isThisSession(entry)),
1856
+ ...entries.filter(isThisSession),
1857
+ ];
1858
+ if (ordered.length <= TEST_RUNNER_MAX_PERSISTED_TARGETS)
1859
+ return ordered;
1860
+ dbg(`turn_end: ${label} test target list held ${ordered.length} entries, bounded to the newest ${TEST_RUNNER_MAX_PERSISTED_TARGETS} (oldest foreign-session rows dropped)`);
1861
+ return ordered.slice(-TEST_RUNNER_MAX_PERSISTED_TARGETS);
1862
+ };
1863
+ const writeTestFindings = (record, ownDeferred) => {
1864
+ const live = cacheManager.readCache("test-runner-findings", cwd)?.data;
1865
+ const own = mergeDeferredTargets(heldDeferred, ownDeferred);
1866
+ const ownKeys = new Set(own.map(entryPathKey));
1867
+ const merged = mergeDeferredTargets(live?.deferredTargets ?? [], own).filter((entry) => {
1868
+ if (!isThisSession(entry))
1869
+ return true;
1870
+ const key = entryPathKey(entry);
1871
+ return !resolvedDeferralKeys.has(key) || ownKeys.has(key);
1872
+ });
1873
+ cacheManager.writeCache("test-runner-findings", {
1874
+ ...record,
1875
+ deferredTargets: boundPersisted(merged, "deferred"),
1876
+ retiredTargets: boundPersisted(mergeDeferredTargets(live?.retiredTargets ?? [], retiredCarry), "retired"),
1877
+ }, cwd);
1878
+ };
1879
+ let deadDeferred = 0;
1880
+ let heldOverCap = 0;
1881
+ let foreignDeferred = 0;
1882
+ let redispatchedDeferred = 0;
1883
+ let retiredThisTurn = 0;
1884
+ for (const carried of carriedDeferred) {
1885
+ const testFile = path.resolve(cwd, carried.testFile);
1886
+ const carriedKey = normalizeMapKey(testFile);
1887
+ // I1: another session still owes this run. Skip it, leave it on the
1888
+ // record — `writeTestFindings` never removes a row this turn does not
1889
+ // own.
1890
+ if (!isThisSession(carried)) {
1891
+ foreignDeferred++;
1892
+ continue;
1893
+ }
1894
+ if (seen.has(carriedKey)) {
1895
+ resolvedDeferralKeys.add(carriedKey);
1896
+ continue;
1897
+ }
1898
+ const attempts = carried.attempts ?? 0;
1899
+ if (attempts >= TEST_RUNNER_MAX_DEFERRALS) {
1900
+ resolvedDeferralKeys.add(carriedKey);
1901
+ if (!retiredKeys.has(carriedKey)) {
1902
+ retiredKeys.add(carriedKey);
1903
+ retiredThisTurn++;
1904
+ retiredCarry.push({
1905
+ testFile,
1906
+ runner: carried.runner,
1907
+ attempts,
1908
+ sessionId: turnSessionId,
1909
+ });
1910
+ // Never silent, and counted rather than once-per-subject: this is
1911
+ // the ledger entry that names WHICH suite is too slow to belong in
1912
+ // a per-turn batch at all.
1913
+ incrementDegradationCount({
1914
+ kind: "test-runner-batch-capped",
1915
+ subject: `${cwd}:deferral-exhausted`,
1916
+ reason: `test target ${path.relative(cwd, testFile)} was cut at the turn-end batch budget ${attempts} turn(s) running and is retired from turn-end selection for the rest of this session — too slow for a per-turn batch, run it explicitly`,
1917
+ });
1918
+ dbg(`turn_end: retiring deferred test target ${path.relative(cwd, testFile)} after ${attempts} cut batch(es) — too slow for the turn-end budget, run it explicitly`);
1919
+ }
1920
+ continue;
1921
+ }
1922
+ // A RunnerConfig carries functions, so the cache stores the runner KEY
1923
+ // and the config is re-resolved here from the single registry.
1924
+ const config = RUNNERS[carried.runner];
1925
+ if (!config ||
1926
+ isExcludedTestTarget(testFile, cwd) ||
1927
+ !fs.existsSync(testFile)) {
1928
+ resolvedDeferralKeys.add(carriedKey);
1929
+ deadDeferred++;
1930
+ continue;
1931
+ }
1932
+ if (targets.length >= TEST_RUNNER_MAX_TARGETS) {
1933
+ // I5: not settled — held, with `attempts` UNCHANGED. It was never
1934
+ // dispatched, so charging it toward retirement would retire a suite
1935
+ // this turn simply had no room for.
1936
+ heldOverCap++;
1937
+ heldDeferred.push({
1938
+ testFile,
1939
+ runner: carried.runner,
1940
+ attempts,
1941
+ sessionId: turnSessionId,
1942
+ });
1943
+ continue;
1944
+ }
1945
+ resolvedDeferralKeys.add(carriedKey);
1946
+ seen.add(carriedKey);
1947
+ redispatchedDeferred++;
1948
+ targets.push({
1949
+ testFile,
1950
+ runner: carried.runner,
1951
+ config,
1952
+ strategy: "deferred",
1953
+ deferralAttempts: attempts,
1954
+ });
1955
+ dbg(`turn_end: re-running deferred test target ${path.relative(cwd, testFile)} (${carried.runner}) from the previous turn's cut batch`);
1956
+ }
1957
+ if (deadDeferred > 0) {
1958
+ dbg(`turn_end: dropped ${deadDeferred} deferred test target(s) that no longer resolve (missing file, excluded, or unknown runner)`);
1959
+ }
1960
+ if (heldOverCap > 0) {
1961
+ dbg(`turn_end: held ${heldOverCap} deferred test target(s) over the per-turn cap of ${TEST_RUNNER_MAX_TARGETS} — kept on the deferral list for the next turn, attempts unchanged`);
1962
+ }
1963
+ if (foreignDeferred > 0) {
1964
+ dbg(`turn_end: ignored ${foreignDeferred} deferred test target(s) belonging to another session — left on the record for the session that cut them`);
1965
+ }
1966
+ if (carriedDeferred.length > 0) {
1967
+ // #2522 review round 4, S1: the ONE pushed record for this path. The
1968
+ // MCP / Stop-hook route calls `handleTurnEnd` with `dbg: noop`, so every
1969
+ // line above is invisible there and the deferral machinery — the part
1970
+ // that decides whether a suite runs at all — left no trace whatsoever
1971
+ // on the route that fires it most.
1972
+ logLatency({
1973
+ type: "phase",
1974
+ toolName: "turn_end",
1975
+ filePath: cwd,
1976
+ phase: "test_runner_deferral",
1977
+ durationMs: 0,
1978
+ metadata: {
1979
+ carried: carriedDeferred.length,
1980
+ redispatched: redispatchedDeferred,
1981
+ heldOverCap,
1982
+ retired: retiredThisTurn,
1983
+ dropped: deadDeferred,
1984
+ foreignSession: foreignDeferred,
1985
+ sessionId: turnSessionId,
1986
+ },
1987
+ });
1988
+ }
1989
+ let overCapTargets = 0;
1990
+ let missingTargetFiles = 0;
1991
+ let excludedTargets = 0;
1992
+ let retiredSkips = 0;
1494
1993
  for (const { display, abs, isNeighbor } of candidates) {
1495
1994
  const target = testRunnerClient.getTestRunTarget(abs, cwd, runtime.turnIndex);
1496
- if (target && !seen.has(target.testFile)) {
1497
- seen.add(target.testFile);
1995
+ const targetKey = target ? normalizeMapKey(target.testFile) : "";
1996
+ if (target && !seen.has(targetKey)) {
1997
+ seen.add(targetKey);
1998
+ // #2522 review round 3, F1: THE gate that makes the deferral cap a
1999
+ // cap. Whichever strategy produced this target — related, self,
2000
+ // failed-first — a target already retired this session for
2001
+ // outrunning the whole batch budget is not fired again. Without
2002
+ // this the retire branch above was decorative: it logged, and the
2003
+ // candidate loop three lines down re-resolved the same file and
2004
+ // spawned it anyway, with its attempt counter reset to zero.
2005
+ if (retiredKeys.has(targetKey)) {
2006
+ retiredSkips++;
2007
+ dbg(`turn_end: ${display} → test target retired earlier this session (outran the turn-end batch budget), skipping spawn (${path.relative(cwd, target.testFile)})`);
2008
+ continue;
2009
+ }
2010
+ // #2522: built-in exclusion for turn-end SELECTION — a resolved
2011
+ // target under an integration/e2e directory or naming convention
2012
+ // is never auto-fired, whichever strategy (failed-first/related/
2013
+ // self) produced it. No per-project config knob (maintainer
2014
+ // decision); see `TURN_END_EXCLUDED_TEST_GLOBS`.
2015
+ // LATENT HAZARD, deliberately left as-is: this `continue` skips the
2016
+ // candidate entirely, including `retireMissingFailedTargets`. If an
2017
+ // excluded target were ever seeded into the persisted failed set
2018
+ // (it cannot be today — nothing writes that set except a runner
2019
+ // RESULT, and an excluded target is never run, so it can never
2020
+ // produce one), it would sit there unretired forever, chosen by
2021
+ // the failed-first strategy on every turn and dropped here on
2022
+ // every turn. Any future writer of the failed set must retire
2023
+ // excluded entries at the write site, not here.
2024
+ if (isExcludedTestTarget(target.testFile, cwd)) {
2025
+ excludedTargets++;
2026
+ dbg(`turn_end: ${display} → test target excluded (integration/e2e), skipping spawn (${path.relative(cwd, target.testFile)})`);
2027
+ continue;
2028
+ }
2029
+ // #2504: a conventional target that no longer exists on disk still
2030
+ // cost a full runner spawn, which came back "Test file not found"
2031
+ // and was then dropped as an expected skip further down. 9 of the
2032
+ // reported turn's 59 spawns were this. One statSync is cheaper
2033
+ // than a vitest process by four orders of magnitude.
2034
+ if (!fs.existsSync(target.testFile)) {
2035
+ missingTargetFiles++;
2036
+ dbg(`turn_end: ${display} → test file missing, skipping spawn (${path.relative(cwd, target.testFile)})`);
2037
+ continue;
2038
+ }
2039
+ if (targets.length >= TEST_RUNNER_MAX_TARGETS) {
2040
+ overCapTargets++;
2041
+ continue;
2042
+ }
1498
2043
  targets.push(target);
1499
2044
  dbg(`turn_end: ${display} → test ${target.runner} ${path.relative(cwd, target.testFile)} (${target.strategy}${isNeighbor ? ", cascade-neighbor" : ""})`);
1500
2045
  }
@@ -1502,11 +2047,30 @@ export async function handleTurnEnd(deps) {
1502
2047
  dbg(`turn_end: ${display} → no test file found${isNeighbor ? " (cascade-neighbor)" : ""}`);
1503
2048
  }
1504
2049
  }
2050
+ if (excludedTargets > 0) {
2051
+ dbg(`turn_end: excluded ${excludedTargets} test target(s) under the built-in integration/e2e exclusion list`);
2052
+ }
2053
+ if (retiredSkips > 0) {
2054
+ dbg(`turn_end: skipped ${retiredSkips} test target(s) retired earlier this session for outrunning the turn-end batch budget`);
2055
+ }
2056
+ if (missingTargetFiles > 0) {
2057
+ dbg(`turn_end: skipped ${missingTargetFiles} test target(s) whose file no longer exists`);
2058
+ }
2059
+ if (overCapTargets > 0) {
2060
+ // Never silent: the agent is told that some of this turn's tests were
2061
+ // not run, rather than reading an all-green batch that covered part
2062
+ // of the edit set.
2063
+ recordDegradationOnce({
2064
+ kind: "test-runner-batch-capped",
2065
+ subject: cwd,
2066
+ reason: `turn touched more test targets than one turn may fire; ran ${TEST_RUNNER_MAX_TARGETS}, skipped ${overCapTargets} — re-run the remainder with lens_diagnostics or edit them in a smaller batch`,
2067
+ });
2068
+ dbg(`turn_end: test target count capped at ${TEST_RUNNER_MAX_TARGETS}, ${overCapTargets} skipped`);
2069
+ }
1505
2070
  if (targets.length > 0) {
1506
- dbg(`turn_end: firing ${targets.length} test target(s) async (non-blocking)`);
2071
+ dbg(`turn_end: firing ${targets.length} test target(s) async (non-blocking, max ${TEST_RUNNER_BATCH_CONCURRENCY} concurrent)`);
1507
2072
  const firedAtTurn = runtime.turnIndex;
1508
- const firedSessionId = runtime.telemetrySessionId;
1509
- const priorTestCache = cacheManager.readCache("test-runner-findings", cwd)?.data;
2073
+ const firedSessionId = turnSessionId;
1510
2074
  const testRunGeneration = (priorTestCache?.testRunGeneration ?? 0) + 1;
1511
2075
  const provenanceFiles = [
1512
2076
  ...candidates.map((candidate) => ({
@@ -1524,13 +2088,91 @@ export async function handleTurnEnd(deps) {
1524
2088
  generation: testRunGeneration,
1525
2089
  files: provenanceFiles,
1526
2090
  });
1527
- cacheManager.writeCache("test-runner-findings", { ...(priorTestCache ?? { content: "" }), testRunGeneration }, cwd);
1528
- Promise.allSettled(targets.map((t) => testRunnerClient.runTestFileAsync(t.testFile, cwd, {
1529
- runner: t.runner,
1530
- config: t.config,
1531
- turnIndex: firedAtTurn,
1532
- })))
1533
- .then((results) => {
2091
+ writeTestFindings({
2092
+ ...(priorTestCache ?? { content: "" }),
2093
+ testRunGeneration,
2094
+ },
2095
+ // Consumed above into `targets` (or retired, or dropped). Anything
2096
+ // this turn HELD is re-asserted by the writer itself; the batch's own
2097
+ // outcome adds the NEW cut set below.
2098
+ []);
2099
+ runTestTargetsBounded({
2100
+ targets,
2101
+ concurrency: TEST_RUNNER_BATCH_CONCURRENCY,
2102
+ budgetMs: TEST_RUNNER_BATCH_BUDGET_MS,
2103
+ // Both bounds, per AGENTS.md: a wall budget AND the ambient
2104
+ // abort signal the rest of the spawn layer already honours.
2105
+ signal: getAmbientAbortSignal(),
2106
+ run: (t, batchSignal) => testRunnerClient.runTestFileAsync(t.testFile, cwd, {
2107
+ runner: t.runner,
2108
+ config: t.config,
2109
+ turnIndex: firedAtTurn,
2110
+ // #2522 R2 F1: the BATCH's signal, so spending the 20s wall
2111
+ // budget kills this spawn rather than letting it run out its
2112
+ // own 60s timeout behind a batch that has already returned.
2113
+ signal: batchSignal,
2114
+ }),
2115
+ })
2116
+ .then(({ results, deferred, stopReason }) => {
2117
+ const deferredTargets = deferred.map((t) => ({
2118
+ testFile: t.testFile,
2119
+ runner: t.runner,
2120
+ // One more cut batch for this target. Read back by the
2121
+ // selection loop above, which retires it at
2122
+ // TEST_RUNNER_MAX_DEFERRALS rather than carrying it forever.
2123
+ attempts: (t.deferralAttempts ?? 0) + 1,
2124
+ // #2522 R3 F5: stamped with the session that cut it, so the
2125
+ // next session re-arms this target instead of inheriting an
2126
+ // attempt count measured under a load it never saw.
2127
+ sessionId: firedSessionId,
2128
+ }));
2129
+ if (deferred.length > 0) {
2130
+ // #2522 review round 2, F4: `incrementDegradationCount`, not
2131
+ // `recordDegradationOnce`. At a 20s budget this is a
2132
+ // RECURRING event, and a once-per-subject entry reports the
2133
+ // tenth cut batch of a session exactly like the first.
2134
+ incrementDegradationCount({
2135
+ kind: "test-runner-batch-capped",
2136
+ subject: `${cwd}:${stopReason ?? "incomplete"}`,
2137
+ reason: `test batch stopped early (${stopReason ?? "incomplete"}); ${deferred.length} target(s) killed or never dispatched, deferred to the next turn`,
2138
+ });
2139
+ dbg(`turn_end: test batch stopped early (${stopReason ?? "incomplete"}), ${deferred.length} target(s) deferred to the next turn`);
2140
+ }
2141
+ const deferralNote = deferred.length > 0
2142
+ ? `${deferred.length} test target(s) did not finish within the turn-end batch budget and are deferred to the next turn (they run first): ${deferredTargets
2143
+ .map((t) => path.relative(cwd, t.testFile))
2144
+ .join(", ")}`
2145
+ : "";
2146
+ /**
2147
+ * A newer batch has already published for this project — this
2148
+ * one's results are stale by construction and must not overwrite
2149
+ * it. Was inlined three times; extracted once round 2 added a
2150
+ * fourth call site.
2151
+ */
2152
+ const supersededByNewerGeneration = (label) => {
2153
+ const current = cacheManager.readCache("test-runner-findings", cwd)?.data;
2154
+ const currentGeneration = current?.testRunGeneration;
2155
+ if (currentGeneration !== undefined &&
2156
+ currentGeneration > testRunGeneration) {
2157
+ dbg(`turn_end: ${label}test generation ${testRunGeneration} superseded by ${currentGeneration}`);
2158
+ // #2522 review round 3, F3: returning here USED to drop this
2159
+ // batch's cut targets entirely. The newer batch's own pre-run
2160
+ // write already cleared them, and it never saw this set — so
2161
+ // with two overlapping batches the targets the older one was
2162
+ // cut on were never deferred, never re-selected, and the suite
2163
+ // they belong to simply never ran again. Hand them over
2164
+ // instead. Round 4, I2: the merge is `writeTestFindings`'s own
2165
+ // rule now, not a second copy of it here — every branch below
2166
+ // merges the same way, so a NEWER batch publishing after this
2167
+ // one no longer flattens what this hand-over just recorded.
2168
+ if (deferredTargets.length > 0) {
2169
+ writeTestFindings({ ...(current ?? { content: "" }) }, deferredTargets);
2170
+ dbg(`turn_end: ${label}carried ${deferredTargets.length} cut target(s) into the newer generation's deferral set`);
2171
+ }
2172
+ return true;
2173
+ }
2174
+ return false;
2175
+ };
1534
2176
  const publishedAgainst = snapshotAdvisoryProvenance({
1535
2177
  cwd,
1536
2178
  runtime,
@@ -1553,13 +2195,40 @@ export async function handleTurnEnd(deps) {
1553
2195
  const stale = runtime.turnIndex !== firedAtTurn;
1554
2196
  const failures = [];
1555
2197
  const resultValues = [];
2198
+ let rejectedCount = 0;
2199
+ // #2522: whether ANY reported item is a genuine failing test
2200
+ // (`failed > 0`) as opposed to the runner itself never
2201
+ // completing (timeout, missing provider/binary, a rejected
2202
+ // promise). Drives the delivery framing below — a batch made
2203
+ // up ENTIRELY of runner errors is not something the agent
2204
+ // introduced, so it must not read as "fix before continuing".
2205
+ let hasRealFailure = false;
1556
2206
  for (const r of results) {
1557
2207
  if (r.status === "rejected") {
2208
+ rejectedCount++;
2209
+ emitBounded("test_runner_delivery", `${cwd}:generation:${testRunGeneration}:rejected`, {
2210
+ filePath: cwd,
2211
+ durationMs: 0,
2212
+ metadata: {
2213
+ outcome: "runner-promise-rejected",
2214
+ sessionId: firedSessionId,
2215
+ generation: testRunGeneration,
2216
+ targetCount: targets.length,
2217
+ droppedDetailCount: 0,
2218
+ reason: String(r.reason).slice(0, 500),
2219
+ },
2220
+ }, {
2221
+ ledgerKind: "test-runner-delivery",
2222
+ reason: "test runner promise rejected",
2223
+ capPerTurn: { limit: 8, turnIndex: firedAtTurn },
2224
+ });
1558
2225
  dbg(`turn_end: test run rejected — ${r.reason}`);
1559
2226
  continue;
1560
2227
  }
1561
2228
  resultValues.push(r.value);
1562
2229
  const { file, runner, passed, failed, duration, error } = r.value;
2230
+ if (failed > 0)
2231
+ hasRealFailure = true;
1563
2232
  const shortFile = path.basename(file);
1564
2233
  // #1479: `(0ms)` used to be printed for a run nobody
1565
2234
  // timed — a payload with no suite timestamps, an
@@ -1581,19 +2250,33 @@ export async function handleTurnEnd(deps) {
1581
2250
  // `elapsed` is: the pair read as a nested ternary, which
1582
2251
  // this line only got flagged for because #1479 touched it.
1583
2252
  const verdict = failed > 0 ? "FAIL" : "PASS";
1584
- const summary = error && passed === 0 && failed === 0
1585
- ? `error: ${error}`
2253
+ // #2532 review S1: folded onto `isRunnerErrorResult` instead of
2254
+ // re-deriving `error && passed === 0 && failed === 0` here — that
2255
+ // local spelling missed a runner error reported alongside partial
2256
+ // passes (pytest `Interrupted` after some tests already passed),
2257
+ // which read as a clean "PASS Np/0f" line with the error silently
2258
+ // dropped. A partial run says so explicitly rather than reading
2259
+ // as a clean pass.
2260
+ const summary = isRunnerErrorResult(r.value)
2261
+ ? passed > 0
2262
+ ? `error: ${error} (${passed} passed before)`
2263
+ : `error: ${error}`
1586
2264
  : `${verdict} ${passed}p/${failed}f (${elapsed})`;
1587
2265
  dbg(`turn_end: ${stale ? "[stale] " : ""}test ${runner} ${shortFile} → ${summary}`);
1588
2266
  // #1524: also fires on `error` alone, not just `failed > 0`.
1589
- // A runner-error result (the suite never started — spawn/
1590
- // config failure) has `failed === 0` by construction, so
1591
- // gating on `failed > 0` alone dropped it silently: the
1592
- // agent got no context at all, and the empty `failures`
1593
- // array below sent this result down the "all tests
1594
- // passed" branch, clearing any prior real test-failure
1595
- // git-guard blocker. `formatResult` already renders the
1596
- // error-only case as "Could not run tests: ...".
2267
+ // A runner error (the suite never started, or was
2268
+ // interrupted before finishing — spawn/config/timeout
2269
+ // failure) can arrive with `failed === 0` even when tests
2270
+ // DID pass before it (#2532 review S2 — not "by
2271
+ // construction": `parsePytestOutput` sets `error` from the
2272
+ // exit code independently of the parsed counts, so
2273
+ // `isRunnerErrorResult` is `failed === 0 && !!error`, not an
2274
+ // invariant elsewhere). Gating on `failed > 0` alone dropped
2275
+ // it silently: the agent got no context at all, and the
2276
+ // empty `failures` array below sent this result down the
2277
+ // "all tests passed" branch, clearing any prior real
2278
+ // test-failure git-guard blocker. `formatResult` already
2279
+ // renders the error-only case as "Could not run tests: ...".
1597
2280
  if (failed > 0 || error) {
1598
2281
  // #2028: "Test file not found" is an expected skip
1599
2282
  // (conventional test path without an actual file),
@@ -1607,17 +2290,32 @@ export async function handleTurnEnd(deps) {
1607
2290
  failures.push(formatted);
1608
2291
  }
1609
2292
  }
2293
+ if (rejectedCount > 0) {
2294
+ failures.push(`Test runner rejected ${rejectedCount} promise(s) before producing a structured result.`);
2295
+ }
1610
2296
  if (failures.length > 0) {
1611
- const currentGeneration = cacheManager.readCache("test-runner-findings", cwd)?.data?.testRunGeneration;
1612
- if (currentGeneration !== undefined &&
1613
- currentGeneration > testRunGeneration) {
1614
- dbg(`turn_end: test generation ${testRunGeneration} superseded by ${currentGeneration}`);
2297
+ if (supersededByNewerGeneration(""))
1615
2298
  return;
1616
- }
1617
- const content = stale
1618
- ? `[from a prior turn — the edit that triggered this run had already been superseded by the time results came back]\n\n${failures.join("\n\n")}`
1619
- : failures.join("\n\n");
1620
- cacheManager.writeCache("test-runner-findings", {
2299
+ const content = [
2300
+ stale
2301
+ ? "[from a prior turn — the edit that triggered this run had already been superseded by the time results came back]"
2302
+ : "",
2303
+ failures.join("\n\n"),
2304
+ // A batch can BOTH find a real failure and be cut short.
2305
+ // The agent needs to see the failure AND that the run was
2306
+ // incomplete, or it reads a partial list as the full one.
2307
+ deferralNote,
2308
+ ]
2309
+ .filter(Boolean)
2310
+ .join("\n\n");
2311
+ // #2522: nothing in this batch is a genuine failing test —
2312
+ // every entry is a runner error (timeout, missing
2313
+ // provider/binary, a rejected promise). `peekTestFindings`
2314
+ // reads this to deliver advisory framing instead of
2315
+ // "fix before continuing", since the agent introduced
2316
+ // nothing here to fix.
2317
+ const runnerErrorOnly = !hasRealFailure;
2318
+ writeTestFindings({
1621
2319
  content,
1622
2320
  stale,
1623
2321
  results: resultValues,
@@ -1626,7 +2324,20 @@ export async function handleTurnEnd(deps) {
1626
2324
  publishedAgainst,
1627
2325
  provenance: publishedAgainst,
1628
2326
  superseded,
1629
- }, cwd);
2327
+ runnerErrorOnly,
2328
+ }, deferredTargets);
2329
+ try {
2330
+ deps.onTestRunnerComplete?.({
2331
+ cwd,
2332
+ sessionId: firedSessionId,
2333
+ generation: testRunGeneration,
2334
+ targetCount: targets.length,
2335
+ hasFindings: true,
2336
+ });
2337
+ }
2338
+ catch (deliveryErr) {
2339
+ dbg(`turn_end: test delivery staging failed — ${deliveryErr}`);
2340
+ }
1630
2341
  if (getFlag("lens-guard") &&
1631
2342
  firedSessionId === runtime.telemetrySessionId) {
1632
2343
  // #1524: `&& !value.error` — a runner-error result has
@@ -1648,13 +2359,117 @@ export async function handleTurnEnd(deps) {
1648
2359
  if (cleanFiles.length > 0) {
1649
2360
  clearGitGuardTestFailure(cacheManager, cwd, runtime, cleanFiles);
1650
2361
  }
1651
- mergeGitGuardTestFailure(cacheManager, cwd, runtime, content, resultValues
1652
- .filter((value) => value.failed > 0)
1653
- .map((value) => value.file));
2362
+ // #2532: `runnerErrorOnly` (computed above for the turn-end
2363
+ // delivery framing) is true exactly when nothing in this
2364
+ // batch is a genuine failing test — every entry is a
2365
+ // runner error the agent did not introduce. Without this
2366
+ // gate, an all-runner-error batch still called
2367
+ // `mergeGitGuardTestFailure` with an EMPTY failed-files
2368
+ // list, which unconditionally sets `hasBlockers: true`:
2369
+ // the identical event the turn-end message reports as
2370
+ // advisory read as "COMMIT BLOCKED" under --lens-guard.
2371
+ if (!runnerErrorOnly) {
2372
+ mergeGitGuardTestFailure(cacheManager, cwd, runtime, content, resultValues
2373
+ .filter((value) => value.failed > 0)
2374
+ .map((value) => value.file));
2375
+ }
2376
+ else {
2377
+ // #2532 review T2: the skip above is otherwise pull-only —
2378
+ // nothing records that a batch with real content
2379
+ // (`failures.length > 0`) was deliberately kept OFF the
2380
+ // `--lens-guard` blocker because every entry was a runner
2381
+ // error. Same phase/ledger as the rejected-promise event
2382
+ // above, so both `--lens-guard` demotions land in one
2383
+ // queryable place. Reached only inside the enclosing
2384
+ // `getFlag("lens-guard") && firedSessionId === …` check —
2385
+ // no point recording a demotion the flag can't act on.
2386
+ emitBounded("test_runner_delivery", `${cwd}:generation:${testRunGeneration}:runner-error-only`, {
2387
+ filePath: cwd,
2388
+ durationMs: 0,
2389
+ metadata: {
2390
+ outcome: "runner-error-only-not-blocking",
2391
+ sessionId: firedSessionId,
2392
+ generation: testRunGeneration,
2393
+ targetCount: targets.length,
2394
+ droppedDetailCount: 0,
2395
+ },
2396
+ }, {
2397
+ ledgerKind: "test-runner-delivery",
2398
+ reason: "runner-error-only batch kept off the --lens-guard blocker",
2399
+ capPerTurn: { limit: 8, turnIndex: firedAtTurn },
2400
+ });
2401
+ }
1654
2402
  }
1655
- dbg(`turn_end: ${failures.length} test failure(s) cached for next context injection${stale ? " (stale — turn advanced while tests ran)" : ""}`);
2403
+ dbg(`turn_end: ${failures.length} test failure(s) cached for pull diagnostics and post-agent delivery${stale ? " (stale — turn advanced while tests ran)" : ""}`);
2404
+ }
2405
+ else if (deferred.length > 0) {
2406
+ // #2522 review round 2, F1: a PARTIAL batch is not a clean
2407
+ // batch. Pre-round-2 this fell through to the branch below —
2408
+ // `content: ""`, "all tests passed", and a `--lens-guard`
2409
+ // clear — on the strength of a run that never finished. The
2410
+ // record names the deferral instead; the commit gate is left
2411
+ // exactly as it was, because nothing here proves anything
2412
+ // passed that was not already proven.
2413
+ if (supersededByNewerGeneration("deferred "))
2414
+ return;
2415
+ writeTestFindings({
2416
+ content: deferralNote,
2417
+ stale,
2418
+ results: resultValues,
2419
+ testRunGeneration,
2420
+ launchedFrom,
2421
+ publishedAgainst,
2422
+ provenance: publishedAgainst,
2423
+ superseded,
2424
+ // Advisory framing: an unfinished batch is not a
2425
+ // failure the agent introduced, exactly like a runner
2426
+ // error. See `TestRunnerFindingsCache.runnerErrorOnly`.
2427
+ runnerErrorOnly: true,
2428
+ }, deferredTargets);
2429
+ try {
2430
+ deps.onTestRunnerComplete?.({
2431
+ cwd,
2432
+ sessionId: firedSessionId,
2433
+ generation: testRunGeneration,
2434
+ targetCount: targets.length,
2435
+ hasFindings: true,
2436
+ });
2437
+ }
2438
+ catch (deliveryErr) {
2439
+ dbg(`turn_end: test delivery staging failed — ${deliveryErr}`);
2440
+ }
2441
+ dbg(`turn_end: partial test batch — ${results.length} target(s) settled, ${deferred.length} deferred to the next turn; NOT recorded as a clean run`);
1656
2442
  }
1657
2443
  else if (results.length > 0) {
2444
+ if (supersededByNewerGeneration("clean "))
2445
+ return;
2446
+ writeTestFindings({
2447
+ ...(priorTestCache ?? { content: "" }),
2448
+ content: "",
2449
+ stale: false,
2450
+ results: resultValues,
2451
+ testRunGeneration,
2452
+ launchedFrom,
2453
+ publishedAgainst,
2454
+ provenance: publishedAgainst,
2455
+ superseded,
2456
+ },
2457
+ // Nothing was cut. This turn's own settled entries drop out;
2458
+ // I2 keeps every row this batch does not own — including a
2459
+ // concurrent older batch's hand-over.
2460
+ []);
2461
+ try {
2462
+ deps.onTestRunnerComplete?.({
2463
+ cwd,
2464
+ sessionId: firedSessionId,
2465
+ generation: testRunGeneration,
2466
+ targetCount: targets.length,
2467
+ hasFindings: false,
2468
+ });
2469
+ }
2470
+ catch (deliveryErr) {
2471
+ dbg(`turn_end: test delivery staging failed — ${deliveryErr}`);
2472
+ }
1658
2473
  if (getFlag("lens-guard") &&
1659
2474
  firedSessionId === runtime.telemetrySessionId) {
1660
2475
  clearGitGuardTestFailure(cacheManager, cwd, runtime, resultValues.map((value) => value.file));
@@ -1664,6 +2479,17 @@ export async function handleTurnEnd(deps) {
1664
2479
  })
1665
2480
  .catch(() => { });
1666
2481
  }
2482
+ else if (carriedDeferred.length > 0) {
2483
+ // Nothing fired, so no batch outcome will write the list back, but this
2484
+ // turn still settled part of it (targets retired at the cap, or dropped
2485
+ // because their file is gone). Persist that here rather than re-reading
2486
+ // a set of dead paths on every future turn; `writeTestFindings` carries
2487
+ // this turn's retirements alongside, which is what the deferral-cap
2488
+ // branch above depends on when it retires the LAST carried target and
2489
+ // fires nothing. Rows this turn only HELD (over the cap) or does not
2490
+ // own (another session's) are re-asserted by the writer itself.
2491
+ writeTestFindings({ ...(priorTestCache ?? { content: "" }) }, []);
2492
+ }
1667
2493
  }
1668
2494
  if (runtime.errorDebtBaseline && files.length > 0) {
1669
2495
  dbg("turn_end: marking error debt check for next session");
@@ -1797,10 +2623,79 @@ export async function handleTurnEnd(deps) {
1797
2623
  fileSeqByPath,
1798
2624
  deltaOnly: !getFlag("lens-actionable-warning-all"),
1799
2625
  dbg,
2626
+ // #2504: this call is AWAITED on the turn_end hook, so its cost is
2627
+ // terminal-blocking time. The file cap and wall budget are its
2628
+ // bounds; the abort signal is the second one AGENTS.md requires.
2629
+ signal: getAmbientAbortSignal(),
2630
+ // A cold-cache turn hands the fresh-pull loop back here, off the
2631
+ // hook — it lands in the same cache the in-band report goes to.
2632
+ onDeferredReport: (deferred) => {
2633
+ try {
2634
+ // #2504 review round 2 (F2): GUARDED. This callback fires up
2635
+ // to a minute after the turn that armed it, and the report
2636
+ // it carries is stamped with THAT turn's
2637
+ // turnIndex/projectSeq. Writing it unconditionally
2638
+ // overwrote a newer report, which `agent_end` then rejected
2639
+ // as `project_seq_mismatch` (silently skipping the autofix
2640
+ // pass) and `lens_diagnostics` re-served as a stale delta.
2641
+ // #2504 review round 4 (F1): guarded PER FILE, not per
2642
+ // report. The round-3 shape (publish, or discard whole
2643
+ // when something newer is persisted) composed with
2644
+ // incumbent-wins into "publish nothing" — every turn_end
2645
+ // with modified files persists an in-band report with a
2646
+ // strictly increasing turnIndex, and the decline fires
2647
+ // exactly when such a turn runs while a loop is in
2648
+ // flight, so a decline always implied a supersede. The
2649
+ // merge upserts the entries whose file has not moved and
2650
+ // drops only those that have.
2651
+ writeDeferredActionableWarningsReport({
2652
+ cacheManager,
2653
+ cwd,
2654
+ report: deferred,
2655
+ // The LIVE per-file sequence at WRITE time is the
2656
+ // baseline the merge judges each entry against. The
2657
+ // runtime is the only thing that knows it: a file
2658
+ // edited into cleanliness by a later turn is absent
2659
+ // from the persisted report entirely.
2660
+ getFileSeq: getFileSeq
2661
+ ? (filePath) => getFileSeq.call(runtime, filePath)
2662
+ : undefined,
2663
+ dbg,
2664
+ });
2665
+ }
2666
+ catch (deferErr) {
2667
+ dbg(`turn_end: deferred actionable-warnings write failed — ${deferErr}`);
2668
+ }
2669
+ },
2670
+ });
2671
+ // #2504 review round 5 (F1): THE publish choke point. This was a
2672
+ // blind `writeActionableWarningsReport` while the deferred callback
2673
+ // above read-modify-wrote the SAME cache key. The deferred merge can
2674
+ // land anywhere inside this handleTurnEnd -- the cascade settle,
2675
+ // knip, madge, the test batch, the in-band LSP enrichment are all
2676
+ // awaited between the moment it is armed and the moment we get
2677
+ // here -- and the blind write erased it 607 ms later in the
2678
+ // reviewer's trace. The publisher now always reads what is persisted
2679
+ // and merges per file, so the two writers cannot race. It carries
2680
+ // forward only entries a DEFERRAL produced, and only while their
2681
+ // file has not moved, so a `turn_delta` report does not accumulate
2682
+ // every prior turn's findings.
2683
+ const publishResult = publishActionableWarningsReport(cacheManager, cwd, report, {
2684
+ origin: "in-band",
2685
+ getFileSeq: getFileSeq
2686
+ ? (filePath) => getFileSeq.call(runtime, filePath)
2687
+ : undefined,
2688
+ dbg,
1800
2689
  });
1801
- writeActionableWarningsReport(cacheManager, cwd, report);
1802
- appendActionableWarningsHistory(cwd, report);
1803
- const advisory = formatActionableWarningsAdvisory(report);
2690
+ // #2504 review round 6 (a): the advisory must read the MERGED report,
2691
+ // not the pre-merge `report` this turn assembled -- a rescued deferred
2692
+ // entry lives only on `publishResult.report`, and formatting the
2693
+ // pre-merge report silently dropped it from the turn_end advisory
2694
+ // even though it was correctly persisted to cache.
2695
+ // #2521: `cwd` is what resolves the store location the advisory
2696
+ // names -- it is the SAME cwd `publishActionableWarningsReport`
2697
+ // just wrote through, so the two cannot disagree.
2698
+ const advisory = formatActionableWarningsAdvisory(publishResult.report, cwd);
1804
2699
  // @delivery-surface: runtime-turn:actionable-warnings-advisory
1805
2700
  if (advisory)
1806
2701
  advisoryParts.push(advisory);
@@ -1809,7 +2704,13 @@ export async function handleTurnEnd(deps) {
1809
2704
  sessionId: runtime.telemetrySessionId,
1810
2705
  metadata: {
1811
2706
  turnIndex: runtime.turnIndex,
1812
- unsuppressed: report.summary.unsuppressed,
2707
+ // #2504 review round 7 (F2): the MERGED report, matching the
2708
+ // advisory text above it (round 6, a) -- a rescued deferred
2709
+ // entry lives only on `publishResult.report`, and the pre-merge
2710
+ // `report` this turn assembled undercounts it. This is the value
2711
+ // scripts/analyze-pi-lens-logs.mjs sums, so an undercount here is
2712
+ // a silent miscount there too.
2713
+ unsuppressed: publishResult.report.summary.unsuppressed,
1813
2714
  },
1814
2715
  });
1815
2716
  logLatency({
@@ -1850,7 +2751,7 @@ export async function handleTurnEnd(deps) {
1850
2751
  });
1851
2752
  writeCodeQualityWarningsReport(cacheManager, cwd, qualityReport);
1852
2753
  appendCodeQualityWarningsHistory(cwd, qualityReport);
1853
- const advisory = formatCodeQualityWarningsAdvisory(qualityReport);
2754
+ const advisory = formatCodeQualityWarningsAdvisory(qualityReport, cwd);
1854
2755
  // @delivery-surface: runtime-turn:code-quality-warnings-advisory
1855
2756
  if (advisory)
1856
2757
  advisoryParts.push(advisory);
@@ -1975,6 +2876,10 @@ export async function handleTurnEnd(deps) {
1975
2876
  let lateAuxExpired = 0;
1976
2877
  let lateAuxCeilingExhausted = 0;
1977
2878
  let lateAuxAnswered = 0;
2879
+ let lateAuxNotifyStallDemoted = 0;
2880
+ const lateAuxCoverageGapPairs = [];
2881
+ let lateAuxCoverageGapDetailCount = 0;
2882
+ let lateAuxCoverageGapDropCount = 0;
1978
2883
  const lateAuxStuckPairs = [];
1979
2884
  if (drainedPairs.length > 0) {
1980
2885
  const byFile = new Map();
@@ -2026,6 +2931,40 @@ export async function handleTurnEnd(deps) {
2026
2931
  lateAuxClientGone += 1;
2027
2932
  continue;
2028
2933
  }
2934
+ if (cachedEntry.notifyStallDemoted) {
2935
+ if (cachedEntry.demotedAt === undefined ||
2936
+ pair.markedAtMs > cachedEntry.demotedAt) {
2937
+ // A pair marked after teardown belongs to the missing
2938
+ // generation, so it follows ordinary clientGone handling.
2939
+ lateAuxClientGone += 1;
2940
+ continue;
2941
+ }
2942
+ // #2356: notify-stall teardown is a transient absence while the
2943
+ // breaker cools down, but only for a pair marked before teardown.
2944
+ lateAuxNotifyStallDemoted += 1;
2945
+ const pastTtl = isPendingAuxiliaryPastRearmTtl(pair);
2946
+ const atCeiling = (pair.rearmCount ?? 0) >= MAX_LATE_AUX_REARMS;
2947
+ if (!pastTtl && !atCeiling) {
2948
+ rearmPendingAuxiliaryCoverage(pair);
2949
+ lateAuxRearmed += 1;
2950
+ if (lateAuxStuckPairs.length < 20)
2951
+ lateAuxStuckPairs.push({
2952
+ filePath: pair.filePath,
2953
+ serverId: pair.serverId,
2954
+ });
2955
+ }
2956
+ else {
2957
+ if (pastTtl)
2958
+ lateAuxExpired += 1;
2959
+ else
2960
+ lateAuxCeilingExhausted += 1;
2961
+ lateAuxCoverageGapPairs.push({
2962
+ filePath: pair.filePath,
2963
+ serverId: pair.serverId,
2964
+ });
2965
+ }
2966
+ continue;
2967
+ }
2029
2968
  const rawDiags = cachedEntry.diags;
2030
2969
  if (cachedEntry.publishedAt === undefined ||
2031
2970
  cachedEntry.publishedAt <= pair.markedAtMs) {
@@ -2117,6 +3056,33 @@ export async function handleTurnEnd(deps) {
2117
3056
  catch (err) {
2118
3057
  dbg(`turn_end: late-auxiliary probe failed: ${err}`);
2119
3058
  }
3059
+ // #2356: a demoted scanner that never gets replaced remains a coverage
3060
+ // gap. Re-raise it once when the existing bounded late-pair window closes,
3061
+ // preserving the server/file identity in both the ledger and latency row.
3062
+ for (const pair of lateAuxCoverageGapPairs) {
3063
+ const normalizedPairPath = normalizeMapKey(pair.filePath);
3064
+ const emitted = emitBounded("lsp_scanner_coverage_gap", `${pair.serverId}:${normalizedPairPath}`, {
3065
+ filePath: normalizedPairPath,
3066
+ durationMs: 0,
3067
+ metadata: {
3068
+ source: "late-auxiliary",
3069
+ serverIds: [pair.serverId],
3070
+ reason: "notify-stall-replacement-unavailable",
3071
+ reRaised: true,
3072
+ },
3073
+ }, {
3074
+ ledgerKind: "lsp-scanner-coverage-gap",
3075
+ reason: "notify-stall replacement was not available before late-coverage ceiling",
3076
+ capPerTurn: {
3077
+ limit: LATE_AUX_COVERAGE_GAP_DETAIL_CAP_PER_TURN,
3078
+ turnIndex: runtime.turnIndex,
3079
+ },
3080
+ });
3081
+ if (emitted)
3082
+ lateAuxCoverageGapDetailCount += 1;
3083
+ else
3084
+ lateAuxCoverageGapDropCount += 1;
3085
+ }
2120
3086
  logLatency({
2121
3087
  type: "phase",
2122
3088
  toolName: "turn_end",
@@ -2137,6 +3103,10 @@ export async function handleTurnEnd(deps) {
2137
3103
  expired: lateAuxExpired,
2138
3104
  ceilingExhausted: lateAuxCeilingExhausted,
2139
3105
  answered: lateAuxAnswered,
3106
+ notifyStallDemoted: lateAuxNotifyStallDemoted,
3107
+ coverageGapReRaised: lateAuxCoverageGapPairs.length,
3108
+ coverageGapReRaisedDetailed: lateAuxCoverageGapDetailCount,
3109
+ coverageGapReRaisedDropped: lateAuxCoverageGapDropCount,
2140
3110
  capEvicted: lateAuxCapEvicted,
2141
3111
  stuckPairs: lateAuxStuckPairs,
2142
3112
  },
@@ -2325,6 +3295,11 @@ export async function handleTurnEnd(deps) {
2325
3295
  // answers that from latency.log even when the payload is empty, and
2326
3296
  // the payload itself carries the retirement note when it is not.
2327
3297
  demotedFindingsRetired,
3298
+ // #2275: the widget-footer store's own dependency-drift retirements —
3299
+ // a separate surface/counter from `demotedFindingsRetired` above,
3300
+ // since a widget retirement never touches `advisoryParts` or the
3301
+ // `turn-end-findings-last` suppression cache the block below clears.
3302
+ widgetDemotedFindingsRetired,
2328
3303
  },
2329
3304
  });
2330
3305
  resetFormatService();