pi-lens 4.1.2 → 4.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (359) hide show
  1. package/CHANGELOG.md +1308 -0
  2. package/README.md +3 -0
  3. package/config/biome/core.jsonc +11 -2
  4. package/config/dependency-cruiser-eager-allowlist.json +91 -0
  5. package/dist/clients/actionable-warnings-logger.js +18 -3
  6. package/dist/clients/actionable-warnings.js +927 -80
  7. package/dist/clients/agent-behavior-client.js +13 -4
  8. package/dist/clients/ast-grep-client.js +136 -9
  9. package/dist/clients/ast-grep-rule-manager.js +60 -5
  10. package/dist/clients/ast-grep-tool-logger.js +2 -2
  11. package/dist/clients/availability-probe-flight.js +12 -0
  12. package/dist/clients/bash-file-access.js +1 -3
  13. package/dist/clients/biome-client.js +16 -2
  14. package/dist/clients/blocker-freshness.js +34 -0
  15. package/dist/clients/bootstrap.js +509 -73
  16. package/dist/clients/bounded-cache.js +152 -12
  17. package/dist/clients/bounded-telemetry.js +115 -23
  18. package/dist/clients/build-identity.js +95 -0
  19. package/dist/clients/bundled-resource-health.js +113 -0
  20. package/dist/clients/bus-events-logger.js +2 -2
  21. package/dist/clients/bus-publish.js +3 -0
  22. package/dist/clients/cache/rule-cache.js +33 -5
  23. package/dist/clients/cache-manager.js +105 -6
  24. package/dist/clients/cache-observability.js +46 -19
  25. package/dist/clients/cargo-manifest.js +422 -0
  26. package/dist/clients/cascade-logger.js +17 -3
  27. package/dist/clients/code-quality-warnings.js +13 -3
  28. package/dist/clients/complexity-client.js +16 -5
  29. package/dist/clients/config-core/deny.js +221 -0
  30. package/dist/clients/config-core/index.js +47 -0
  31. package/dist/clients/config-core/merge.js +357 -0
  32. package/dist/clients/config-core/normalize.js +340 -0
  33. package/dist/clients/config-core/process-spec.js +248 -0
  34. package/dist/clients/config-core/provenance.js +171 -0
  35. package/dist/clients/config-core/records.js +218 -0
  36. package/dist/clients/config-core/resolve.js +125 -0
  37. package/dist/clients/config-core/safe-object.js +78 -0
  38. package/dist/clients/config-core/schema.js +167 -0
  39. package/dist/clients/config-diagnostic-codes.js +281 -0
  40. package/dist/clients/config-locations.js +160 -0
  41. package/dist/clients/config-resolve.js +789 -0
  42. package/dist/clients/config-schema.js +197 -0
  43. package/dist/clients/config-warn.js +407 -0
  44. package/dist/clients/dead-code-client.js +33 -25
  45. package/dist/clients/dead-code-logger.js +2 -2
  46. package/dist/clients/deadline-utils.js +178 -0
  47. package/dist/clients/debug-handles.js +2 -2
  48. package/dist/clients/debug-heap.js +3 -3
  49. package/dist/clients/deferred-lsp-work.js +106 -0
  50. package/dist/clients/degradation-ledger.js +166 -8
  51. package/dist/clients/demoted-finding-render.js +14 -0
  52. package/dist/clients/dependency-checker.js +28 -22
  53. package/dist/clients/deps/minimatch.js +1 -1
  54. package/dist/clients/diagnostic-line-freshness.js +12 -9
  55. package/dist/clients/diagnostic-logger.js +14 -4
  56. package/dist/clients/dispatch/collect-later-tier.js +39 -0
  57. package/dist/clients/dispatch/dispatcher.js +304 -19
  58. package/dist/clients/dispatch/fact-store.js +328 -9
  59. package/dist/clients/dispatch/integration.js +214 -107
  60. package/dist/clients/dispatch/pending-runner-findings.js +87 -0
  61. package/dist/clients/dispatch/plan.js +21 -5
  62. package/dist/clients/dispatch/runners/actionlint.js +0 -1
  63. package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
  64. package/dist/clients/dispatch/runners/biome-check.js +0 -1
  65. package/dist/clients/dispatch/runners/cpp-check.js +0 -1
  66. package/dist/clients/dispatch/runners/credo.js +0 -1
  67. package/dist/clients/dispatch/runners/cue-vet.js +0 -1
  68. package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  69. package/dist/clients/dispatch/runners/detekt.js +0 -1
  70. package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  71. package/dist/clients/dispatch/runners/elixir-check.js +0 -1
  72. package/dist/clients/dispatch/runners/eslint.js +5 -2
  73. package/dist/clients/dispatch/runners/fact-rules.js +0 -1
  74. package/dist/clients/dispatch/runners/fish-indent.js +0 -1
  75. package/dist/clients/dispatch/runners/gleam-check.js +0 -1
  76. package/dist/clients/dispatch/runners/go-vet.js +1 -3
  77. package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  78. package/dist/clients/dispatch/runners/hadolint.js +0 -1
  79. package/dist/clients/dispatch/runners/helm-lint.js +16 -4
  80. package/dist/clients/dispatch/runners/helm-render.js +54 -10
  81. package/dist/clients/dispatch/runners/htmlhint.js +0 -1
  82. package/dist/clients/dispatch/runners/index.js +1 -3
  83. package/dist/clients/dispatch/runners/javac.js +0 -1
  84. package/dist/clients/dispatch/runners/ktlint.js +0 -1
  85. package/dist/clients/dispatch/runners/lsp.js +37 -2
  86. package/dist/clients/dispatch/runners/markdownlint.js +4 -2
  87. package/dist/clients/dispatch/runners/mypy.js +0 -1
  88. package/dist/clients/dispatch/runners/oxlint.js +24 -22
  89. package/dist/clients/dispatch/runners/php-lint.js +0 -1
  90. package/dist/clients/dispatch/runners/phpstan.js +0 -1
  91. package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  92. package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
  93. package/dist/clients/dispatch/runners/pyright.js +7 -4
  94. package/dist/clients/dispatch/runners/rubocop.js +0 -1
  95. package/dist/clients/dispatch/runners/ruff.js +0 -1
  96. package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
  97. package/dist/clients/dispatch/runners/shellcheck.js +0 -1
  98. package/dist/clients/dispatch/runners/shfmt.js +0 -1
  99. package/dist/clients/dispatch/runners/spellcheck.js +0 -1
  100. package/dist/clients/dispatch/runners/spotbugs.js +0 -1
  101. package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  102. package/dist/clients/dispatch/runners/stylelint.js +0 -1
  103. package/dist/clients/dispatch/runners/swiftlint.js +0 -1
  104. package/dist/clients/dispatch/runners/taplo.js +0 -1
  105. package/dist/clients/dispatch/runners/terragrunt.js +0 -1
  106. package/dist/clients/dispatch/runners/tflint.js +0 -1
  107. package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  108. package/dist/clients/dispatch/runners/trivy-config.js +0 -1
  109. package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  110. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
  111. package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
  112. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  113. package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  114. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
  115. package/dist/clients/dispatch/runners/vale.js +0 -1
  116. package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  117. package/dist/clients/dispatch/runners/yamllint.js +0 -1
  118. package/dist/clients/dispatch/runners/zig-check.js +0 -1
  119. package/dist/clients/disposition-logger.js +3 -3
  120. package/dist/clients/effective-config.js +403 -0
  121. package/dist/clients/error-class.js +23 -0
  122. package/dist/clients/event-loop-hold.js +274 -0
  123. package/dist/clients/event-loop-monitor.js +66 -1
  124. package/dist/clients/extension-log.js +2 -2
  125. package/dist/clients/feature-hints.js +2 -1
  126. package/dist/clients/file-time.js +6 -1
  127. package/dist/clients/file-utils.js +266 -13
  128. package/dist/clients/finding-delivery-gate.js +55 -13
  129. package/dist/clients/format-service.js +6 -1
  130. package/dist/clients/formatters.js +303 -104
  131. package/dist/clients/freshness-cadence.js +17 -0
  132. package/dist/clients/generated-artifacts.js +114 -19
  133. package/dist/clients/generation-guard.js +4 -9
  134. package/dist/clients/git-guard.js +198 -48
  135. package/dist/clients/git-tracked-ignore.js +32 -1
  136. package/dist/clients/go-client.js +39 -0
  137. package/dist/clients/govulncheck-client.js +5 -1
  138. package/dist/clients/gradle-ktfmt-style.js +252 -0
  139. package/dist/clients/gzip-stage-write.js +7 -0
  140. package/dist/clients/hashline-anchor.js +424 -0
  141. package/dist/clients/hook-budgets.js +76 -0
  142. package/dist/clients/host-edit-normalize.js +5 -2
  143. package/dist/clients/host-ports.js +1 -1
  144. package/dist/clients/installer/index.js +200 -84
  145. package/dist/clients/installer/managed-tool-refresh.js +3 -1
  146. package/dist/clients/instance-reaper.js +108 -243
  147. package/dist/clients/instance-registry-lock.js +145 -0
  148. package/dist/clients/instance-registry.js +435 -122
  149. package/dist/clients/jscpd-client.js +6 -1
  150. package/dist/clients/language-profile.js +4 -2
  151. package/dist/clients/language-registry.js +595 -0
  152. package/dist/clients/latency-logger.js +179 -3
  153. package/dist/clients/ledger-bounds.js +13 -0
  154. package/dist/clients/lens-config.js +141 -30
  155. package/dist/clients/lens-engine.js +30 -37
  156. package/dist/clients/lens-flag-registry.js +67 -6
  157. package/dist/clients/lens-map.js +3 -1
  158. package/dist/clients/log-cleanup.js +2 -2
  159. package/dist/clients/lsp/aggregation.js +3 -1
  160. package/dist/clients/lsp/cascade-tier.js +4 -0
  161. package/dist/clients/lsp/client.js +672 -49
  162. package/dist/clients/lsp/config.js +504 -156
  163. package/dist/clients/lsp/diagnostic-binding.js +10 -5
  164. package/dist/clients/lsp/edits.js +15 -2
  165. package/dist/clients/lsp/index.js +1179 -247
  166. package/dist/clients/lsp/jvm-runtime.js +5 -0
  167. package/dist/clients/lsp/language.js +18 -169
  168. package/dist/clients/lsp/launch.js +38 -9
  169. package/dist/clients/lsp/pending-aux-coverage.js +113 -12
  170. package/dist/clients/lsp/server.js +426 -160
  171. package/dist/clients/lsp/session-roots.js +192 -0
  172. package/dist/clients/lsp/spawn-history.js +2 -7
  173. package/dist/clients/lsp/tsserver-sync.js +9 -2
  174. package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
  175. package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  176. package/dist/clients/lsp-mutation.js +179 -23
  177. package/dist/clients/map-with-concurrency.js +36 -0
  178. package/dist/clients/mcp/analyze.js +45 -10
  179. package/dist/clients/mcp/session.js +19 -17
  180. package/dist/clients/memory-sampler.js +28 -1
  181. package/dist/clients/message-end-attribution.js +30 -0
  182. package/dist/clients/metrics-history.js +21 -4
  183. package/dist/clients/middle-man-analysis.js +2 -4
  184. package/dist/clients/module-report.js +20 -45
  185. package/dist/clients/mutating-tool.js +651 -0
  186. package/dist/clients/mutation-attribution.js +368 -0
  187. package/dist/clients/mutation-bridge.js +240 -0
  188. package/dist/clients/ndjson-logger.js +247 -20
  189. package/dist/clients/observed-mutation-sources.js +101 -0
  190. package/dist/clients/observed-mutation.js +1215 -0
  191. package/dist/clients/opaque-mutation-scan.js +300 -40
  192. package/dist/clients/opengrep-config.js +4 -0
  193. package/dist/clients/package-manager.js +201 -44
  194. package/dist/clients/partial-edit-apply.js +359 -80
  195. package/dist/clients/path-attribution-telemetry.js +23 -7
  196. package/dist/clients/path-keyed-map.js +35 -3
  197. package/dist/clients/path-utils.js +460 -13
  198. package/dist/clients/persist-debounce.js +8 -1
  199. package/dist/clients/php-cs-fixer-config.js +114 -0
  200. package/dist/clients/pipeline.js +77 -24
  201. package/dist/clients/probe-home-state.js +228 -0
  202. package/dist/clients/process-bridge.js +66 -0
  203. package/dist/clients/process-singletons.js +210 -0
  204. package/dist/clients/process-snapshot.js +68 -0
  205. package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  206. package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  207. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
  208. package/dist/clients/project-diagnostics/scanner.js +61 -24
  209. package/dist/clients/project-lens-config.js +393 -122
  210. package/dist/clients/project-snapshot.js +85 -35
  211. package/dist/clients/python-environment.js +217 -0
  212. package/dist/clients/python-provenance.js +639 -0
  213. package/dist/clients/quiet-window.js +6 -1
  214. package/dist/clients/read-bridge.js +13 -21
  215. package/dist/clients/read-expansion.js +9 -46
  216. package/dist/clients/read-guard-logger.js +39 -6
  217. package/dist/clients/read-guard-tool-lines.js +364 -212
  218. package/dist/clients/read-guard.js +95 -7
  219. package/dist/clients/recent-touches.js +17 -5
  220. package/dist/clients/resource-sampler.js +195 -96
  221. package/dist/clients/review-graph/builder.js +752 -132
  222. package/dist/clients/review-graph/service.js +55 -3
  223. package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  224. package/dist/clients/review-graph/tsconfig-paths.js +5 -1
  225. package/dist/clients/review-graph/workspace-modules.js +10 -74
  226. package/dist/clients/review-graph-logger.js +30 -4
  227. package/dist/clients/runtime-agent-end.js +18 -2
  228. package/dist/clients/runtime-context.js +49 -16
  229. package/dist/clients/runtime-coordinator.js +93 -6
  230. package/dist/clients/runtime-session.js +169 -34
  231. package/dist/clients/runtime-tool-call.js +183 -35
  232. package/dist/clients/runtime-tool-result.js +894 -241
  233. package/dist/clients/runtime-turn.js +1264 -74
  234. package/dist/clients/rust-client.js +31 -0
  235. package/dist/clients/safe-spawn.js +191 -28
  236. package/dist/clients/sanitize.js +7 -1
  237. package/dist/clients/scratch-tree-policy.js +1 -3
  238. package/dist/clients/security-scan-client.js +51 -17
  239. package/dist/clients/session-lifecycle.js +238 -42
  240. package/dist/clients/session-start-observability.js +79 -0
  241. package/dist/clients/sessionstart-logger.js +12 -3
  242. package/dist/clients/sg-runner.js +27 -15
  243. package/dist/clients/sgconfig.js +6 -1
  244. package/dist/clients/shared-checkout-guard.js +338 -0
  245. package/dist/clients/skills-resolver.js +105 -0
  246. package/dist/clients/smells-rollup.js +2 -2
  247. package/dist/clients/spawn-output-cap.js +37 -0
  248. package/dist/clients/spawn-timeout-cooldown.js +15 -1
  249. package/dist/clients/startup-scan.js +6 -7
  250. package/dist/clients/startup-timing.js +69 -4
  251. package/dist/clients/string-utils.js +33 -0
  252. package/dist/clients/subagent-mode.js +17 -4
  253. package/dist/clients/test-runner-client.js +586 -118
  254. package/dist/clients/test-runner-delivery.js +239 -0
  255. package/dist/clients/tool-definition.js +41 -1
  256. package/dist/clients/tool-policy.js +92 -22
  257. package/dist/clients/tree-sitter-cache.js +40 -40
  258. package/dist/clients/tree-sitter-client.js +97 -53
  259. package/dist/clients/tree-sitter-logger.js +19 -3
  260. package/dist/clients/tree-sitter-query-loader.js +81 -1
  261. package/dist/clients/tree-sitter-shared.js +51 -46
  262. package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  263. package/dist/clients/typos-config.js +5 -0
  264. package/dist/clients/user-notify.js +6 -2
  265. package/dist/clients/warm-attach.js +8 -10
  266. package/dist/clients/widget-state.js +191 -26
  267. package/dist/clients/word-index-logger.js +15 -3
  268. package/dist/clients/word-index-store.js +463 -0
  269. package/dist/clients/word-index.js +815 -125
  270. package/dist/clients/workspace-topology.js +25 -1
  271. package/dist/clients/zizmor-config.js +29 -6
  272. package/dist/index.js +68577 -53950
  273. package/dist/mcp/analyze-cli.js +3 -1
  274. package/dist/mcp/build-staleness.js +8 -7
  275. package/dist/mcp/cli.js +3 -1
  276. package/dist/mcp/server.js +133 -6
  277. package/dist/scripts/lib/process-scan.mjs +583 -0
  278. package/dist/scripts/lib/skills-predicate.mjs +129 -0
  279. package/dist/tools/effective-config.js +89 -0
  280. package/dist/tools/lens-diagnostics.js +99 -24
  281. package/dist/tools/lsp-diagnostics.js +81 -85
  282. package/dist/tools/lsp-navigation.js +40 -17
  283. package/dist/tools/shared.js +0 -1
  284. package/docs/agent-guide.md +68 -3
  285. package/docs/ast-grep_rules_catalog.md +41 -10
  286. package/docs/astplayground.md +26 -17
  287. package/docs/configuration.md +222 -0
  288. package/docs/dependencies.md +3 -3
  289. package/docs/features.md +44 -5
  290. package/docs/language-coverage.md +2 -2
  291. package/docs/pi-lens-fixer.md +25 -0
  292. package/docs/pi-lens-investigator.md +25 -0
  293. package/docs/pi-lens-reviewer.md +27 -0
  294. package/docs/pi-lens-subagent.md +38 -0
  295. package/docs/pi-lens-warden.md +55 -0
  296. package/docs/public-api-stability.md +359 -0
  297. package/docs/release-qa-baseline.md +182 -0
  298. package/docs/subagent-compat.md +110 -30
  299. package/docs/tree-sitter_rules_catalog.md +2 -2
  300. package/package.json +17 -21
  301. package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  302. package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  303. package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  304. package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  305. package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  306. package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  307. package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  308. package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  309. package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  310. package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  311. package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  312. package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  313. package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  314. package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  315. package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  316. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  317. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  318. package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  319. package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  320. package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  321. package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  322. package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  323. package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  324. package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  325. package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  326. package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  327. package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  328. package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  329. package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  330. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  331. package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  332. package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  333. package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  334. package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  335. package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  336. package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  337. package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  338. package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  339. package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  340. package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  341. package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  342. package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  343. package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  344. package/rules/ast-grep-rules/rules/no-important.yml +21 -0
  345. package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  346. package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  347. package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  348. package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  349. package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  350. package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  351. package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  352. package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  353. package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  354. package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  355. package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  356. package/scripts/analyze-pi-lens-logs.mjs +192 -1
  357. package/scripts/install-selftest.mjs +58 -2
  358. package/scripts/lib/skills-predicate.mjs +129 -0
  359. package/scripts/rpc-load-check.mjs +3 -1
@@ -1,11 +1,13 @@
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";
6
7
  import { cascadeSettleWaitMs } from "./cascade-budget.js";
7
8
  import { logCascade } from "./cascade-logger.js";
8
9
  import { normalizeMapKey } from "./path-utils.js";
10
+ import { compareOrdinal } from "./string-utils.js";
9
11
  import { resolveRunnerPath, toRunnerDisplayPath, } from "./dispatch/runner-context.js";
10
12
  import { getKnipIgnorePatterns } from "./file-utils.js";
11
13
  import { formatCacheAgeLabel } from "./finding-delivery-gate.js";
@@ -28,20 +30,251 @@ import { emitLensTurnFindings } from "./lens-events.js";
28
30
  import { RUNTIME_CONFIG } from "./runtime-config.js";
29
31
  import { isSubagentSession } from "./subagent-mode.js";
30
32
  import { formatRunDurationMs } from "./run-duration.js";
33
+ import { isExcludedTestTarget, isRunnerErrorResult, RUNNERS, } from "./test-runner-client.js";
31
34
  import { MAX_ADVISORY_AFFECTED_FILES, gateFindingsByPathFreshness, snapshotAdvisoryProvenance, } from "./advisory-provenance.js";
32
- import { sweepInlineBlockerFreshness } from "./blocker-freshness.js";
35
+ import { DEPENDENCY_DRIFT_MAX_DELIVERIES, sweepInlineBlockerFreshness, } from "./blocker-freshness.js";
33
36
  import { sweepInlineBlockerPastEof } from "./blocker-past-eof.js";
34
37
  // #2001/#2002: collect-later delivery for slow auxiliary LSP servers.
35
38
  import { getLSPService } from "./lsp/index.js";
36
- import { drainPendingAuxiliaryCoverage, isPendingAuxiliaryPastRearmTtl, markPendingAuxiliaryCoverage, } from "./lsp/pending-aux-coverage.js";
39
+ import { drainPendingAuxCapEvictedCount, drainPendingAuxiliaryCoverage, isPendingAuxiliaryPastRearmTtl, rearmPendingAuxiliaryCoverage, MAX_LATE_AUX_REARMS, pendingAuxiliaryCoverageSize, } from "./lsp/pending-aux-coverage.js";
37
40
  import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
41
+ import { drainPendingRunnerFindings, dropStaleRunnerFindings, pendingRunnerFindingsSize, } from "./dispatch/pending-runner-findings.js";
38
42
  // #1631 review V2: moved to its own leaf module so a low-level store
39
43
  // (widget-state.ts) can use the marker without importing this orchestrator —
40
44
  // see clients/stale-marker.ts's doc comment.
41
- import { incrementDegradationCount } from "./degradation-ledger.js";
42
- import { degradeDemotedFindingBody, formatRetirementNote, } from "./demoted-finding-render.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";
49
+ import { degradeDemotedFindingBody, formatDeliveryCapNote, formatRetirementNote, } from "./demoted-finding-render.js";
43
50
  import { STALE_LINE_MARKER } from "./stale-marker.js";
44
- import { getWidgetBlockingFilesForSweep, markWidgetFileBlockersStale, } from "./widget-state.js";
51
+ import { getActiveSessionId } from "./session-lifecycle.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
+ }
45
278
  /**
46
279
  * #1617: turn_end reads gitleaks/govulncheck/trivy straight from their
47
280
  * session-scan caches and formats them into advisory/blocker text — a
@@ -152,6 +385,9 @@ function scheduleLSPIdleReset(resetFn, delayMs, options = {}) {
152
385
  if (options.isCurrentSession && !options.isCurrentSession()) {
153
386
  return;
154
387
  }
388
+ if (options.isPrimarySession && !options.isPrimarySession()) {
389
+ return;
390
+ }
155
391
  resetFn();
156
392
  }
157
393
  catch (err) {
@@ -233,7 +469,7 @@ function capTurnEndMessage(content) {
233
469
  return out;
234
470
  }
235
471
  export async function handleTurnEnd(deps) {
236
- 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;
237
473
  // #449 slice 1: piggyback the instance-registry heartbeat on this existing
238
474
  // per-turn touchpoint rather than adding a new timer/interval. Cheap (reads
239
475
  // process.memoryUsage().rss, one read-modify-write of instances.json) and
@@ -255,13 +491,27 @@ export async function handleTurnEnd(deps) {
255
491
  let turnState = cacheManager.readTurnState(cwd);
256
492
  // A live foreign writer owns this worklist. Do not clear or consume another
257
493
  // pi/MCP session's files; a dead/aged owner is safely evicted instead.
258
- const currentOwner = owner ?? {
259
- kind: "pi",
260
- id: runtime.telemetrySessionId,
261
- pid: process.pid,
262
- 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,
263
505
  };
264
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";
265
515
  const sameProcessPiSessionHandoff = access === "foreign-live" &&
266
516
  currentOwner.kind === "pi" &&
267
517
  turnState.owner?.kind === "pi" &&
@@ -278,6 +528,49 @@ export async function handleTurnEnd(deps) {
278
528
  turnState = cacheManager.readTurnState(cwd);
279
529
  }
280
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
+ }
281
574
  // R1 (#1443 follow-up): a read-only turn (no files touched) must not take
282
575
  // the fast idle-reset path while a carried cascade run — or one still
283
576
  // settling — is waiting for its delivery opportunity. Falling through to
@@ -286,6 +579,11 @@ export async function handleTurnEnd(deps) {
286
579
  // instead of dying unrendered. `hasCascadeRuns()` is a cheap peek (no
287
580
  // pending work almost every turn), so the common read-only turn still
288
581
  // takes the early return below.
582
+ // A foreign live owner must not deliver another session's pending findings.
583
+ // A no-file turn falls through when this process has pending runner work so
584
+ // the ordinary freshness gate and delivery cache can run. Max-cycle cleanup
585
+ // below intentionally remains a terminal reset; its pending work stays in
586
+ // the bounded handoff store for the next eligible turn.
289
587
  if (files.length === 0 && !runtime.hasCascadeRuns()) {
290
588
  // A genuinely clean session must invalidate the persisted guard record.
291
589
  // Blocker records are retained only while the runtime still reports one.
@@ -310,11 +608,20 @@ export async function handleTurnEnd(deps) {
310
608
  const sessionGeneration = runtime.sessionGeneration;
311
609
  scheduleLSPIdleReset(resetLSPService, idleResetMs, {
312
610
  isCurrentSession: () => runtime.isCurrentSession(sessionGeneration),
611
+ // #2157 fix round 2: a secondary (subagent) evaluation's own timer
612
+ // must not release the primary's shared fleet — see the option's
613
+ // doc comment on `scheduleLSPIdleReset`.
614
+ isPrimarySession: () => {
615
+ const activePrimarySessionId = getActiveSessionId();
616
+ return (activePrimarySessionId === undefined ||
617
+ activePrimarySessionId === runtime.telemetrySessionId);
618
+ },
313
619
  onError: (err) => dbg(`lsp idle reset failed: ${err}`),
314
620
  });
315
621
  }
316
622
  resetFormatService();
317
- return;
623
+ if (pendingRunnerFindingsSize() === 0)
624
+ return;
318
625
  }
319
626
  // Cancel any pending idle reset since we're actively working. #1618: also
320
627
  // checks `pendingSweepRearm` — a timer deferred behind an in-flight
@@ -323,11 +630,11 @@ export async function handleTurnEnd(deps) {
323
630
  // read "nothing pending" and skip the cancel while a rearm was still
324
631
  // queued to fire the instant the sweep released its hold — resurrecting
325
632
  // idle reset on a session that had since gone back to active editing.
326
- if (lspIdleResetTimeout || pendingSweepRearm) {
633
+ if (files.length > 0 && (lspIdleResetTimeout || pendingSweepRearm)) {
327
634
  cancelLSPIdleReset();
328
635
  dbg("turn_end: cancelled pending LSP idle reset (active editing)");
329
636
  }
330
- 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}`);
331
638
  if (cacheManager.isMaxCyclesExceeded(cwd)) {
332
639
  dbg("turn_end: max cycles exceeded, clearing state and forcing through");
333
640
  cacheManager.clearTurnState(cwd, currentOwner);
@@ -404,9 +711,20 @@ export async function handleTurnEnd(deps) {
404
711
  // Re-surface inline blockers from this turn that the agent didn't fix.
405
712
  // These were shown inline during write/edit but the agent moved on without resolving them.
406
713
  const unresolvedBlockers = runtime.getInlineBlockersSnapshot();
407
- /** #1944: past-EOF demotions retired after their single degraded delivery. */
714
+ /** #1944/#1950: demotions retired after their delivery limit. */
408
715
  let demotedFindingsRetired = 0;
409
- for (const { filePath: bPath, summary, stale } of unresolvedBlockers) {
716
+ /**
717
+ * #1950 fix-round F1: dependency-drift delivery-count commits, deferred
718
+ * until this turn's content is confirmed NOT suppressed by the
719
+ * `turn-end-findings-last` signature dedupe further down. That dedupe
720
+ * silences a turn whose rendered content is byte-identical to the last
721
+ * one actually delivered — the agent never sees a suppressed turn, so
722
+ * committing the counter for it would count a delivery that didn't
723
+ * happen. Each entry here is invoked only from the "not suppressed"
724
+ * branch below.
725
+ */
726
+ const pendingDependencyDriftDeliveries = [];
727
+ for (const { filePath: bPath, summary, stale, staleReason, } of unresolvedBlockers) {
410
728
  const displayPath = toRunnerDisplayPath(cwd, bPath);
411
729
  if (stale) {
412
730
  // #1631: demoted — out of the authoritative blocker channel and into the
@@ -423,6 +741,7 @@ export async function handleTurnEnd(deps) {
423
741
  const deadLines = blockerPastEof.deadLinesByPath.get(bPath) ?? [];
424
742
  const degraded = degradeDemotedFindingBody(summary, { deadLines });
425
743
  const retired = runtime.retireDemotedPastEofBlocker(bPath, deadLines);
744
+ let retirementNote;
426
745
  if (retired) {
427
746
  demotedFindingsRetired += 1;
428
747
  // Bounded by the ledger's own per-kind/subject tally, and the subject
@@ -432,10 +751,43 @@ export async function handleTurnEnd(deps) {
432
751
  subject: `inline-blocker:${displayPath}`,
433
752
  reason: `file shrank past cited line(s) ${deadLines.join(", ")}; retired after one degraded delivery`,
434
753
  });
754
+ retirementNote = formatRetirementNote(deadLines);
755
+ }
756
+ else if (staleReason === "dependency-drift") {
757
+ // #1950: a dependency-drift demotion is recoverable (its coordinates
758
+ // are still in bounds), so it does NOT retire after one delivery like
759
+ // the past-EOF case above — but nothing capped how many times the
760
+ // SAME demoted-but-unconfirmed record re-serves, and incident data
761
+ // showed repeat deliveries carrying near-zero information after the
762
+ // first. Cap it at DEPENDENCY_DRIFT_MAX_DELIVERIES instead.
763
+ //
764
+ // The count driving THIS render is a peek (fix-round F1): the actual
765
+ // increment is deferred to `pendingDependencyDriftDeliveries` below,
766
+ // committed only once this turn's content is known to reach the
767
+ // agent, so a suppressed turn's tentative render never advances the
768
+ // stored count.
769
+ const tentativeCount = runtime.peekInlineBlockerStaleDeliveryCount(bPath) + 1;
770
+ if (tentativeCount >= DEPENDENCY_DRIFT_MAX_DELIVERIES) {
771
+ retirementNote = formatDeliveryCapNote(tentativeCount);
772
+ }
773
+ pendingDependencyDriftDeliveries.push(() => {
774
+ const deliveryCount = runtime.incrementInlineBlockerStaleDelivery(bPath);
775
+ if (deliveryCount >= DEPENDENCY_DRIFT_MAX_DELIVERIES) {
776
+ const capRetired = runtime.retireDemotedDependencyDriftBlocker(bPath);
777
+ if (capRetired) {
778
+ demotedFindingsRetired += 1;
779
+ incrementDegradationCount({
780
+ kind: "demoted-finding-retired",
781
+ subject: `inline-blocker:${displayPath}`,
782
+ reason: `capped after ${deliveryCount} deliveries with no re-run; re-run can still confirm`,
783
+ });
784
+ }
785
+ }
786
+ });
435
787
  }
436
788
  // @delivery-surface: runtime-turn:unresolved-inline-blocker
437
789
  advisoryParts.push(`${STALE_LINE_MARKER} ${displayPath}:\n${degraded.body}` +
438
- (retired ? `\n${formatRetirementNote(deadLines)}` : ""));
790
+ (retirementNote ? `\n${retirementNote}` : ""));
439
791
  }
440
792
  else {
441
793
  // @delivery-surface: runtime-turn:unresolved-inline-blocker
@@ -1392,10 +1744,12 @@ export async function handleTurnEnd(deps) {
1392
1744
  metadata: madgeStats ?? { skipped: true },
1393
1745
  });
1394
1746
  // --- Test runner: fire once per turn after all edits are done ---
1395
- // Runs for each unique test target across modified files; results appear
1396
- // 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.
1397
1749
  if (!getFlag("no-tests") && files.length > 0) {
1398
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).
1399
1753
  const targets = [];
1400
1754
  // #628: also target the test companions of this turn's cascade neighbors
1401
1755
  // (files that import an edited file) — a neighbor's own tests can break
@@ -1426,10 +1780,266 @@ export async function handleTurnEnd(deps) {
1426
1780
  candidates.push({ display: neighbor.filePath, abs, isNeighbor: true });
1427
1781
  }
1428
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;
1429
1993
  for (const { display, abs, isNeighbor } of candidates) {
1430
- const target = testRunnerClient.getTestRunTarget(abs, cwd);
1431
- if (target && !seen.has(target.testFile)) {
1432
- seen.add(target.testFile);
1994
+ const target = testRunnerClient.getTestRunTarget(abs, cwd, runtime.turnIndex);
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
+ }
1433
2043
  targets.push(target);
1434
2044
  dbg(`turn_end: ${display} → test ${target.runner} ${path.relative(cwd, target.testFile)} (${target.strategy}${isNeighbor ? ", cascade-neighbor" : ""})`);
1435
2045
  }
@@ -1437,11 +2047,30 @@ export async function handleTurnEnd(deps) {
1437
2047
  dbg(`turn_end: ${display} → no test file found${isNeighbor ? " (cascade-neighbor)" : ""}`);
1438
2048
  }
1439
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
+ }
1440
2070
  if (targets.length > 0) {
1441
- 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)`);
1442
2072
  const firedAtTurn = runtime.turnIndex;
1443
- const firedSessionId = runtime.telemetrySessionId;
1444
- const priorTestCache = cacheManager.readCache("test-runner-findings", cwd)?.data;
2073
+ const firedSessionId = turnSessionId;
1445
2074
  const testRunGeneration = (priorTestCache?.testRunGeneration ?? 0) + 1;
1446
2075
  const provenanceFiles = [
1447
2076
  ...candidates.map((candidate) => ({
@@ -1459,9 +2088,91 @@ export async function handleTurnEnd(deps) {
1459
2088
  generation: testRunGeneration,
1460
2089
  files: provenanceFiles,
1461
2090
  });
1462
- cacheManager.writeCache("test-runner-findings", { ...(priorTestCache ?? { content: "" }), testRunGeneration }, cwd);
1463
- Promise.allSettled(targets.map((t) => testRunnerClient.runTestFileAsync(t.testFile, cwd, t.runner, t.config)))
1464
- .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
+ };
1465
2176
  const publishedAgainst = snapshotAdvisoryProvenance({
1466
2177
  cwd,
1467
2178
  runtime,
@@ -1484,13 +2195,40 @@ export async function handleTurnEnd(deps) {
1484
2195
  const stale = runtime.turnIndex !== firedAtTurn;
1485
2196
  const failures = [];
1486
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;
1487
2206
  for (const r of results) {
1488
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
+ });
1489
2225
  dbg(`turn_end: test run rejected — ${r.reason}`);
1490
2226
  continue;
1491
2227
  }
1492
2228
  resultValues.push(r.value);
1493
2229
  const { file, runner, passed, failed, duration, error } = r.value;
2230
+ if (failed > 0)
2231
+ hasRealFailure = true;
1494
2232
  const shortFile = path.basename(file);
1495
2233
  // #1479: `(0ms)` used to be printed for a run nobody
1496
2234
  // timed — a payload with no suite timestamps, an
@@ -1512,19 +2250,33 @@ export async function handleTurnEnd(deps) {
1512
2250
  // `elapsed` is: the pair read as a nested ternary, which
1513
2251
  // this line only got flagged for because #1479 touched it.
1514
2252
  const verdict = failed > 0 ? "FAIL" : "PASS";
1515
- const summary = error && passed === 0 && failed === 0
1516
- ? `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}`
1517
2264
  : `${verdict} ${passed}p/${failed}f (${elapsed})`;
1518
2265
  dbg(`turn_end: ${stale ? "[stale] " : ""}test ${runner} ${shortFile} → ${summary}`);
1519
2266
  // #1524: also fires on `error` alone, not just `failed > 0`.
1520
- // A runner-error result (the suite never started — spawn/
1521
- // config failure) has `failed === 0` by construction, so
1522
- // gating on `failed > 0` alone dropped it silently: the
1523
- // agent got no context at all, and the empty `failures`
1524
- // array below sent this result down the "all tests
1525
- // passed" branch, clearing any prior real test-failure
1526
- // git-guard blocker. `formatResult` already renders the
1527
- // 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: ...".
1528
2280
  if (failed > 0 || error) {
1529
2281
  // #2028: "Test file not found" is an expected skip
1530
2282
  // (conventional test path without an actual file),
@@ -1538,17 +2290,32 @@ export async function handleTurnEnd(deps) {
1538
2290
  failures.push(formatted);
1539
2291
  }
1540
2292
  }
2293
+ if (rejectedCount > 0) {
2294
+ failures.push(`Test runner rejected ${rejectedCount} promise(s) before producing a structured result.`);
2295
+ }
1541
2296
  if (failures.length > 0) {
1542
- const currentGeneration = cacheManager.readCache("test-runner-findings", cwd)?.data?.testRunGeneration;
1543
- if (currentGeneration !== undefined &&
1544
- currentGeneration > testRunGeneration) {
1545
- dbg(`turn_end: test generation ${testRunGeneration} superseded by ${currentGeneration}`);
2297
+ if (supersededByNewerGeneration(""))
1546
2298
  return;
1547
- }
1548
- const content = stale
1549
- ? `[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")}`
1550
- : failures.join("\n\n");
1551
- 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({
1552
2319
  content,
1553
2320
  stale,
1554
2321
  results: resultValues,
@@ -1557,7 +2324,20 @@ export async function handleTurnEnd(deps) {
1557
2324
  publishedAgainst,
1558
2325
  provenance: publishedAgainst,
1559
2326
  superseded,
1560
- }, 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
+ }
1561
2341
  if (getFlag("lens-guard") &&
1562
2342
  firedSessionId === runtime.telemetrySessionId) {
1563
2343
  // #1524: `&& !value.error` — a runner-error result has
@@ -1579,13 +2359,117 @@ export async function handleTurnEnd(deps) {
1579
2359
  if (cleanFiles.length > 0) {
1580
2360
  clearGitGuardTestFailure(cacheManager, cwd, runtime, cleanFiles);
1581
2361
  }
1582
- mergeGitGuardTestFailure(cacheManager, cwd, runtime, content, resultValues
1583
- .filter((value) => value.failed > 0)
1584
- .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
+ }
2402
+ }
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
+ });
1585
2437
  }
1586
- dbg(`turn_end: ${failures.length} test failure(s) cached for next context injection${stale ? " (stale — turn advanced while tests ran)" : ""}`);
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`);
1587
2442
  }
1588
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
+ }
1589
2473
  if (getFlag("lens-guard") &&
1590
2474
  firedSessionId === runtime.telemetrySessionId) {
1591
2475
  clearGitGuardTestFailure(cacheManager, cwd, runtime, resultValues.map((value) => value.file));
@@ -1595,6 +2479,17 @@ export async function handleTurnEnd(deps) {
1595
2479
  })
1596
2480
  .catch(() => { });
1597
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
+ }
1598
2493
  }
1599
2494
  if (runtime.errorDebtBaseline && files.length > 0) {
1600
2495
  dbg("turn_end: marking error debt check for next session");
@@ -1728,10 +2623,79 @@ export async function handleTurnEnd(deps) {
1728
2623
  fileSeqByPath,
1729
2624
  deltaOnly: !getFlag("lens-actionable-warning-all"),
1730
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,
1731
2689
  });
1732
- writeActionableWarningsReport(cacheManager, cwd, report);
1733
- appendActionableWarningsHistory(cwd, report);
1734
- 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);
1735
2699
  // @delivery-surface: runtime-turn:actionable-warnings-advisory
1736
2700
  if (advisory)
1737
2701
  advisoryParts.push(advisory);
@@ -1740,7 +2704,13 @@ export async function handleTurnEnd(deps) {
1740
2704
  sessionId: runtime.telemetrySessionId,
1741
2705
  metadata: {
1742
2706
  turnIndex: runtime.turnIndex,
1743
- 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,
1744
2714
  },
1745
2715
  });
1746
2716
  logLatency({
@@ -1781,7 +2751,7 @@ export async function handleTurnEnd(deps) {
1781
2751
  });
1782
2752
  writeCodeQualityWarningsReport(cacheManager, cwd, qualityReport);
1783
2753
  appendCodeQualityWarningsHistory(cwd, qualityReport);
1784
- const advisory = formatCodeQualityWarningsAdvisory(qualityReport);
2754
+ const advisory = formatCodeQualityWarningsAdvisory(qualityReport, cwd);
1785
2755
  // @delivery-surface: runtime-turn:code-quality-warnings-advisory
1786
2756
  if (advisory)
1787
2757
  advisoryParts.push(advisory);
@@ -1809,6 +2779,78 @@ export async function handleTurnEnd(deps) {
1809
2779
  });
1810
2780
  }
1811
2781
  cacheManager.incrementTurnCycle(cwd, currentOwner);
2782
+ // Collect-later CLI runners continue off the write path. Their completed
2783
+ // diagnostics use the same freshness gate as late auxiliary findings and
2784
+ // enter the ordinary turn-end advisory delivery channel.
2785
+ const runnerFindingsStart = Date.now();
2786
+ // Turn-end delivery is deliberately non-blocking. Collect already-settled
2787
+ // results and requeue the rest; the edit path already paid the deferral
2788
+ // decision, so another 2s wait would charge every turn while a runner is
2789
+ // still in flight (#2122 F5).
2790
+ const pendingRunnerFindings = await drainPendingRunnerFindings(0);
2791
+ let runnerFindingsDelivered = 0;
2792
+ let runnerFindingsStale = 0;
2793
+ let runnerFindingsFailed = 0;
2794
+ let runnerFindingsDropped = 0;
2795
+ const runnerFindingsDeliveredIds = [];
2796
+ for (const pending of pendingRunnerFindings) {
2797
+ const result = pending.result;
2798
+ if (!result)
2799
+ continue;
2800
+ recordRunner(pending.filePath, pending.runnerId, result.status, result.diagnostics.length, Date.now() - pending.markedAtMs, pending.writeIndex);
2801
+ if (result.status === "failed") {
2802
+ runnerFindingsFailed += 1;
2803
+ const detail = result.failureMessage ? `: ${result.failureMessage}` : "";
2804
+ // @delivery-surface: runtime-turn:late-runner-findings
2805
+ advisoryParts.push(`❌ Deferred runner ${pending.runnerId} failed (${result.failureKind ?? "unknown"})${detail}`);
2806
+ continue;
2807
+ }
2808
+ const findings = result.diagnostics;
2809
+ if (findings.length === 0)
2810
+ continue;
2811
+ const gate = gateFindingsByPathFreshness({
2812
+ store: "late-runner-findings",
2813
+ findings,
2814
+ cwd,
2815
+ scannedAt: pending.markedAtMs,
2816
+ citedPath: (finding) => finding.filePath,
2817
+ });
2818
+ runnerFindingsStale += gate.stale.length;
2819
+ if (gate.stale.length > 0) {
2820
+ // The runner answered for bytes older than the latest edit. Do not
2821
+ // re-arm this completed answer: only a new runner query can restore
2822
+ // coverage for the refreshed bytes.
2823
+ dropStaleRunnerFindings(pending);
2824
+ runnerFindingsDropped += 1;
2825
+ }
2826
+ if (gate.live.length === 0)
2827
+ continue;
2828
+ const displayPath = toRunnerDisplayPath(cwd, pending.filePath);
2829
+ const lines = gate.live.map((finding) => ` ${displayPath}:${finding.line ?? 1}:${finding.column ?? 1} [${finding.rule ?? finding.id}] ${finding.message}`);
2830
+ runnerFindingsDelivered += gate.live.length;
2831
+ for (const finding of gate.live) {
2832
+ if (runnerFindingsDeliveredIds.length < 50) {
2833
+ runnerFindingsDeliveredIds.push(finding.id);
2834
+ }
2835
+ }
2836
+ // @delivery-surface: runtime-turn:late-runner-findings
2837
+ advisoryParts.push(`⏱️ Late runner diagnostics (${pending.runnerId} completed after the edit):\n${lines.join("\n")}`);
2838
+ }
2839
+ logLatency({
2840
+ type: "phase",
2841
+ toolName: "turn_end",
2842
+ filePath: cwd,
2843
+ phase: "late_runner_findings",
2844
+ durationMs: Date.now() - runnerFindingsStart,
2845
+ metadata: {
2846
+ pending: pendingRunnerFindings.length,
2847
+ delivered: runnerFindingsDelivered,
2848
+ stale: runnerFindingsStale,
2849
+ failed: runnerFindingsFailed,
2850
+ dropped: runnerFindingsDropped,
2851
+ deliveredIds: runnerFindingsDeliveredIds,
2852
+ },
2853
+ });
1812
2854
  // #2001/#2002: collect-later delivery for auxiliary LSP servers whose
1813
2855
  // aux-grace window expired without a publication (opengrep on Windows:
1814
2856
  // ~8s per scan against a 2s grace — the scanner's eventual findings sat
@@ -1820,12 +2862,25 @@ export async function handleTurnEnd(deps) {
1820
2862
  // client drops silently.
1821
2863
  const lateAuxStart = Date.now();
1822
2864
  const drainedPairs = drainPendingAuxiliaryCoverage();
2865
+ // #2168: cap evictions retire a pair before any drain can observe it — read
2866
+ // and reset that count here so it folds into this turn's reconciliation
2867
+ // sum instead of the pair vanishing uncounted.
2868
+ const lateAuxCapEvicted = drainPendingAuxCapEvictedCount();
1823
2869
  let lateAuxDelivered = 0;
1824
2870
  let lateAuxStale = 0;
1825
2871
  let lateAuxMissing = 0;
1826
2872
  let lateAuxRearmed = 0;
1827
2873
  let lateAuxClientGone = 0;
1828
2874
  let lateAuxProbeFailed = 0;
2875
+ let lateAuxCleanConfirmed = 0;
2876
+ let lateAuxExpired = 0;
2877
+ let lateAuxCeilingExhausted = 0;
2878
+ let lateAuxAnswered = 0;
2879
+ let lateAuxNotifyStallDemoted = 0;
2880
+ const lateAuxCoverageGapPairs = [];
2881
+ let lateAuxCoverageGapDetailCount = 0;
2882
+ let lateAuxCoverageGapDropCount = 0;
2883
+ const lateAuxStuckPairs = [];
1829
2884
  if (drainedPairs.length > 0) {
1830
2885
  const byFile = new Map();
1831
2886
  for (const pair of drainedPairs) {
@@ -1843,23 +2898,76 @@ export async function handleTurnEnd(deps) {
1843
2898
  cached = await service.readCachedDiagnosticsForServers(lateAuxPath, new Set(pairs.map((p) => p.serverId)));
1844
2899
  }
1845
2900
  catch {
1846
- // #2027 round-1 P3-2: count dropped pairs — never silent.
2901
+ // #2027 round-1 P3-2 / #2167 R2-2: a transient probe rejection
2902
+ // tells us nothing about the pair's content, so treat it like the
2903
+ // "still scanning" branch below — re-arm under the SAME
2904
+ // ceiling/TTL bound rather than dropping the coverage outright.
2905
+ // `probeFailed` stays an honest per-turn failure count; it is
2906
+ // informational (like `stale`/`missing`), not a terminal bucket,
2907
+ // since the pair itself still resolves through rearmed/
2908
+ // ceilingExhausted/expired below.
1847
2909
  lateAuxProbeFailed += pairs.length;
1848
2910
  for (const pair of pairs) {
1849
- markPendingAuxiliaryCoverage(pair.filePath, [pair.serverId], Date.now());
2911
+ if (!isPendingAuxiliaryPastRearmTtl(pair) &&
2912
+ (pair.rearmCount ?? 0) < MAX_LATE_AUX_REARMS) {
2913
+ rearmPendingAuxiliaryCoverage(pair);
2914
+ lateAuxRearmed += 1;
2915
+ }
2916
+ else if (isPendingAuxiliaryPastRearmTtl(pair)) {
2917
+ lateAuxExpired += 1;
2918
+ }
2919
+ else {
2920
+ lateAuxCeilingExhausted += 1;
2921
+ }
1850
2922
  }
1851
2923
  continue;
1852
2924
  }
1853
2925
  const displayLateAuxPath = toRunnerDisplayPath(cwd, lateAuxPath);
1854
2926
  for (const pair of pairs) {
1855
- const rawDiags = cached.get(pair.serverId);
1856
- if (rawDiags === undefined) {
2927
+ const cachedEntry = cached.get(pair.serverId);
2928
+ if (cachedEntry === undefined) {
1857
2929
  // No live client for this server any more — best-effort probe,
1858
2930
  // drop the pair silently.
1859
2931
  lateAuxClientGone += 1;
1860
2932
  continue;
1861
2933
  }
1862
- if (rawDiags.length === 0) {
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
+ }
2968
+ const rawDiags = cachedEntry.diags;
2969
+ if (cachedEntry.publishedAt === undefined ||
2970
+ cachedEntry.publishedAt <= pair.markedAtMs) {
1863
2971
  // Still scanning (or published nothing yet) — keep waiting
1864
2972
  // so a scan finishing before the NEXT turn end still
1865
2973
  // delivers. Two clocks, deliberately decoupled: the
@@ -1867,17 +2975,36 @@ export async function handleTurnEnd(deps) {
1867
2975
  // the delivery gate stats against — while the re-arm TTL is
1868
2976
  // anchored on `lastRearmedAtMs`, advanced by every successful
1869
2977
  // empty probe: the scanner is demonstrably alive, just slow.
1870
- if (!isPendingAuxiliaryPastRearmTtl(pair)) {
1871
- markPendingAuxiliaryCoverage(lateAuxPath, [pair.serverId], pair.markedAtMs, Date.now());
2978
+ if (!isPendingAuxiliaryPastRearmTtl(pair) &&
2979
+ (pair.rearmCount ?? 0) < MAX_LATE_AUX_REARMS) {
2980
+ rearmPendingAuxiliaryCoverage(pair);
1872
2981
  lateAuxRearmed += 1;
2982
+ if (lateAuxStuckPairs.length < 20)
2983
+ lateAuxStuckPairs.push({
2984
+ filePath: pair.filePath,
2985
+ serverId: pair.serverId,
2986
+ });
2987
+ }
2988
+ else {
2989
+ if (isPendingAuxiliaryPastRearmTtl(pair))
2990
+ lateAuxExpired += 1;
2991
+ else
2992
+ lateAuxCeilingExhausted += 1;
1873
2993
  }
1874
2994
  continue;
1875
2995
  }
2996
+ if (rawDiags.length === 0) {
2997
+ lateAuxCleanConfirmed += 1;
2998
+ continue;
2999
+ }
1876
3000
  const converted = convertLspDiagnostics(rawDiags, lateAuxPath, {
1877
3001
  tool: "lsp",
1878
3002
  });
1879
- if (converted.length === 0)
3003
+ if (converted.length === 0) {
3004
+ lateAuxMissing += rawDiags.length;
3005
+ lateAuxAnswered += 1;
1880
3006
  continue;
3007
+ }
1881
3008
  // Freshness kernel (#1634 gated surface): stat the cited file
1882
3009
  // against the mark timestamp. Missing → drop (no remediation for
1883
3010
  // a deleted file); mtime drifted past the mark → drop too, NOT
@@ -1897,16 +3024,30 @@ export async function handleTurnEnd(deps) {
1897
3024
  lateAuxStale += gate.stale.length;
1898
3025
  lateAuxMissing +=
1899
3026
  converted.length - gate.live.length - gate.stale.length;
1900
- if (gate.stale.length > 0) {
1901
- // Stale findings mean the scan predates the last edit.
1902
- // Re-arm with a REFRESHED baseline so the next turn probes
1903
- // the newer revision (#2027 round-1 P2-1).
1904
- markPendingAuxiliaryCoverage(lateAuxPath, [pair.serverId], Date.now());
1905
- }
1906
- if (gate.live.length === 0)
3027
+ if (gate.live.length === 0) {
3028
+ if (gate.stale.length > 0) {
3029
+ // Stale findings mean the scan predates the last edit. Re-arm
3030
+ // with a refreshed baseline and carry the ceiling count.
3031
+ if (!isPendingAuxiliaryPastRearmTtl(pair) &&
3032
+ (pair.rearmCount ?? 0) < MAX_LATE_AUX_REARMS) {
3033
+ rearmPendingAuxiliaryCoverage(pair, Date.now(), true);
3034
+ lateAuxRearmed += 1;
3035
+ }
3036
+ else if (isPendingAuxiliaryPastRearmTtl(pair)) {
3037
+ lateAuxExpired += 1;
3038
+ }
3039
+ else {
3040
+ lateAuxCeilingExhausted += 1;
3041
+ }
3042
+ }
3043
+ else {
3044
+ lateAuxAnswered += 1;
3045
+ }
1907
3046
  continue;
3047
+ }
1908
3048
  const lines = gate.live.map((f) => ` ${displayLateAuxPath}:${f.line}:${f.column} [${f.rule}] ${f.message}`);
1909
3049
  lateAuxDelivered += gate.live.length;
3050
+ lateAuxAnswered += 1;
1910
3051
  // @delivery-surface: runtime-turn:late-auxiliary-findings
1911
3052
  advisoryParts.push(`🕐 Late auxiliary diagnostics (${pair.serverId} answered after its grace window):\n${lines.join("\n")}`);
1912
3053
  }
@@ -1915,6 +3056,33 @@ export async function handleTurnEnd(deps) {
1915
3056
  catch (err) {
1916
3057
  dbg(`turn_end: late-auxiliary probe failed: ${err}`);
1917
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
+ }
1918
3086
  logLatency({
1919
3087
  type: "phase",
1920
3088
  toolName: "turn_end",
@@ -1923,12 +3091,24 @@ export async function handleTurnEnd(deps) {
1923
3091
  durationMs: Date.now() - lateAuxStart,
1924
3092
  metadata: {
1925
3093
  pending: drainedPairs.length,
3094
+ pairCreated: drainedPairs.length + lateAuxCapEvicted,
3095
+ pendingAfter: pendingAuxiliaryCoverageSize(),
1926
3096
  delivered: lateAuxDelivered,
1927
3097
  stale: lateAuxStale,
1928
3098
  missing: lateAuxMissing,
1929
3099
  rearmed: lateAuxRearmed,
1930
3100
  clientGone: lateAuxClientGone,
1931
3101
  probeFailed: lateAuxProbeFailed,
3102
+ cleanConfirmed: lateAuxCleanConfirmed,
3103
+ expired: lateAuxExpired,
3104
+ ceilingExhausted: lateAuxCeilingExhausted,
3105
+ answered: lateAuxAnswered,
3106
+ notifyStallDemoted: lateAuxNotifyStallDemoted,
3107
+ coverageGapReRaised: lateAuxCoverageGapPairs.length,
3108
+ coverageGapReRaisedDetailed: lateAuxCoverageGapDetailCount,
3109
+ coverageGapReRaisedDropped: lateAuxCoverageGapDropCount,
3110
+ capEvicted: lateAuxCapEvicted,
3111
+ stuckPairs: lateAuxStuckPairs,
1932
3112
  },
1933
3113
  });
1934
3114
  }
@@ -1945,7 +3125,7 @@ export async function handleTurnEnd(deps) {
1945
3125
  const content = capTurnEndMessage(findingParts.join("\n\n"));
1946
3126
  const signature = `${files
1947
3127
  .slice()
1948
- .sort((a, b) => a.localeCompare(b))
3128
+ .sort((a, b) => compareOrdinal(a, b))
1949
3129
  .join("|")}::${content}`;
1950
3130
  const last = cacheManager.readCache("turn-end-findings-last", cwd);
1951
3131
  if (last?.data?.signature === signature &&
@@ -1980,6 +3160,11 @@ export async function handleTurnEnd(deps) {
1980
3160
  resetFormatService();
1981
3161
  return;
1982
3162
  }
3163
+ // #1950 fix-round F1: this turn's content is confirmed NOT suppressed —
3164
+ // it is about to reach the agent — so NOW commit the delivery-count
3165
+ // increments the per-blocker loop above only tentatively computed.
3166
+ for (const commit of pendingDependencyDriftDeliveries)
3167
+ commit();
1983
3168
  const fileSeqByPath = {};
1984
3169
  for (const [filePath, seq] of runtime.getFileSeqEntries()) {
1985
3170
  fileSeqByPath[normalizeMapKey(path.resolve(filePath))] = seq;
@@ -2110,6 +3295,11 @@ export async function handleTurnEnd(deps) {
2110
3295
  // answers that from latency.log even when the payload is empty, and
2111
3296
  // the payload itself carries the retirement note when it is not.
2112
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,
2113
3303
  },
2114
3304
  });
2115
3305
  resetFormatService();