pi-lens 4.2.1 → 4.4.0

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 (671) hide show
  1. package/CHANGELOG.md +1562 -0
  2. package/README.md +157 -24
  3. package/config/dependency-cruiser-eager-allowlist.json +6 -2
  4. package/dist/chunk-22AVIGXS.js +560 -0
  5. package/dist/chunk-27BBHSZC.js +106 -0
  6. package/dist/chunk-2BEMIWEW.js +51 -0
  7. package/dist/chunk-2EECBNC5.js +89 -0
  8. package/dist/chunk-34U7PLGE.js +23 -0
  9. package/dist/chunk-3I5QFT3N.js +181 -0
  10. package/dist/chunk-3MED75HT.js +37 -0
  11. package/dist/chunk-47D2BO4S.js +87 -0
  12. package/dist/chunk-4RERDEXR.js +348 -0
  13. package/dist/chunk-4W2PLC4J.js +27 -0
  14. package/dist/chunk-52H5B5TC.js +202 -0
  15. package/dist/chunk-54B42YRX.js +27 -0
  16. package/dist/chunk-5RXBNSW2.js +266 -0
  17. package/dist/chunk-5THXD6X5.js +201 -0
  18. package/dist/chunk-5W3R6LFT.js +24 -0
  19. package/dist/chunk-5ZEJHV35.js +341 -0
  20. package/dist/chunk-6P62QVEA.js +118 -0
  21. package/dist/chunk-6QIUX4EP.js +459 -0
  22. package/dist/chunk-6VCD4VKZ.js +78 -0
  23. package/dist/chunk-7F72PXY2.js +326 -0
  24. package/dist/chunk-7FBF7NPR.js +998 -0
  25. package/dist/chunk-7U2IDNXN.js +37 -0
  26. package/dist/chunk-ABBOA7UD.js +157 -0
  27. package/dist/chunk-AHDW3GPE.js +50 -0
  28. package/dist/chunk-AJ3QXTRQ.js +756 -0
  29. package/dist/chunk-AJVLWNFN.js +1610 -0
  30. package/dist/chunk-AJXODZY6.js +112 -0
  31. package/dist/chunk-ARFCXG7S.js +84 -0
  32. package/dist/chunk-AS44CFQ4.js +708 -0
  33. package/dist/chunk-AWE5TMPO.js +38 -0
  34. package/dist/chunk-B3I2UUT4.js +4692 -0
  35. package/dist/chunk-BRJKOBZP.js +331 -0
  36. package/dist/chunk-BW5KFIQC.js +59 -0
  37. package/dist/chunk-C2AUYXQH.js +149 -0
  38. package/dist/chunk-CGGAIP7J.js +29 -0
  39. package/dist/chunk-D6QS3U6M.js +30 -0
  40. package/dist/chunk-DFVMIKMQ.js +349 -0
  41. package/dist/chunk-DIULIWKU.js +169 -0
  42. package/dist/chunk-DQ2NZ7C4.js +2317 -0
  43. package/dist/chunk-E2WFBRHM.js +213 -0
  44. package/dist/chunk-E4MAEDPM.js +50 -0
  45. package/dist/chunk-EOKRQ3SA.js +30 -0
  46. package/dist/chunk-F3AU2TMM.js +2626 -0
  47. package/dist/chunk-F3P7VQ7H.js +170 -0
  48. package/dist/chunk-FC4RP4I2.js +12 -0
  49. package/dist/chunk-FJNKANOS.js +32 -0
  50. package/dist/chunk-GUOGVTCQ.js +146 -0
  51. package/dist/chunk-GVXA6CCE.js +18 -0
  52. package/dist/chunk-HHYAELLJ.js +2334 -0
  53. package/dist/chunk-HMFXILPV.js +83 -0
  54. package/dist/chunk-HVUZXSWK.js +421 -0
  55. package/dist/chunk-HWBZIGMJ.js +62 -0
  56. package/dist/chunk-I3R7GKDR.js +17 -0
  57. package/dist/chunk-I7SPGOWI.js +55 -0
  58. package/dist/chunk-IIXOAGHR.js +38 -0
  59. package/dist/chunk-IVP5KAB7.js +2250 -0
  60. package/dist/chunk-IXEBJSUE.js +22 -0
  61. package/dist/chunk-JOLITGCC.js +27 -0
  62. package/dist/chunk-K6VYLB2U.js +145 -0
  63. package/dist/chunk-LJDODBBZ.js +78 -0
  64. package/dist/chunk-LNAKEO63.js +78 -0
  65. package/dist/chunk-LSMJ6KBE.js +4139 -0
  66. package/dist/chunk-LW6XKDZQ.js +5565 -0
  67. package/dist/chunk-N3YQJI6O.js +899 -0
  68. package/dist/chunk-NABLC7W3.js +148 -0
  69. package/dist/chunk-NKOUFPQF.js +659 -0
  70. package/dist/chunk-NL5GLMFI.js +211 -0
  71. package/dist/chunk-NMABWBZN.js +327 -0
  72. package/dist/chunk-NTHH2J74.js +27 -0
  73. package/dist/chunk-NTUJEGDZ.js +59 -0
  74. package/dist/chunk-NXL4FFQQ.js +47 -0
  75. package/dist/chunk-NXPS2L6U.js +466 -0
  76. package/dist/chunk-O5WILGMI.js +42 -0
  77. package/dist/chunk-O6TQT6RI.js +48 -0
  78. package/dist/chunk-OAXNDZJ7.js +323 -0
  79. package/dist/chunk-ODEEOXPH.js +44 -0
  80. package/dist/chunk-OQGFCZJ4.js +4533 -0
  81. package/dist/chunk-ORTMY4Q4.js +282 -0
  82. package/dist/chunk-OS32RNR2.js +442 -0
  83. package/dist/chunk-PAK6IDME.js +189 -0
  84. package/dist/chunk-PWFHBTSF.js +406 -0
  85. package/dist/chunk-Q5U6FMDI.js +2352 -0
  86. package/dist/chunk-QOZXJ4NA.js +3364 -0
  87. package/dist/chunk-QYF5S6P6.js +45 -0
  88. package/dist/chunk-RIISMHKN.js +36 -0
  89. package/dist/chunk-RPD4ZSB7.js +25 -0
  90. package/dist/chunk-RPD5K5NC.js +79 -0
  91. package/dist/chunk-RUOG5D2C.js +262 -0
  92. package/dist/chunk-T4ERT73Y.js +599 -0
  93. package/dist/chunk-T6D4Q4AQ.js +54 -0
  94. package/dist/chunk-TGPXLY2J.js +391 -0
  95. package/dist/chunk-TLGFBM3F.js +12116 -0
  96. package/dist/chunk-TO6WL3GM.js +88 -0
  97. package/dist/chunk-TT22N74J.js +517 -0
  98. package/dist/chunk-TVN5N6GY.js +21216 -0
  99. package/dist/chunk-U3VGR4VU.js +308 -0
  100. package/dist/chunk-UK3CMEAL.js +1250 -0
  101. package/dist/chunk-UOPVJQ4N.js +103 -0
  102. package/dist/chunk-UXVNL3D7.js +23 -0
  103. package/dist/chunk-UY752QSN.js +45 -0
  104. package/dist/chunk-V5PXFSZK.js +188 -0
  105. package/dist/chunk-V7R425XG.js +398 -0
  106. package/dist/chunk-VCSRSYLF.js +81 -0
  107. package/dist/chunk-VD4JQCQC.js +162 -0
  108. package/dist/chunk-VDBPVIKD.js +62 -0
  109. package/dist/chunk-VHQLPIRJ.js +114 -0
  110. package/dist/chunk-VN2AXLEX.js +5414 -0
  111. package/dist/chunk-VNMT7C64.js +478 -0
  112. package/dist/chunk-WONERJTP.js +309 -0
  113. package/dist/chunk-XECLWQZN.js +85 -0
  114. package/dist/chunk-XIMYZ3JA.js +27 -0
  115. package/dist/chunk-XK4CIILI.js +144 -0
  116. package/dist/chunk-Y4T27COV.js +562 -0
  117. package/dist/chunk-YGVYKNSN.js +1860 -0
  118. package/dist/chunk-YLF3PPT5.js +308 -0
  119. package/dist/chunk-YSNFMJKN.js +14 -0
  120. package/dist/chunk-Z3CCVDUB.js +76 -0
  121. package/dist/chunk-Z54ZSOBZ.js +61 -0
  122. package/dist/chunk-ZAWW3AF6.js +97 -0
  123. package/dist/chunk-ZMOL6RQC.js +167 -0
  124. package/dist/index.js +19039 -8956
  125. package/dist/mcp/analyze-cli.js +186 -228
  126. package/dist/mcp/cli.js +137 -98
  127. package/dist/mcp/server.js +22592 -1564
  128. package/dist/mcp/worker.js +97 -38
  129. package/dist/probes/installer.js +135 -0
  130. package/dist/workers/project-snapshot-persist-worker.js +22 -0
  131. package/dist/workers/review-graph-persist-worker.js +15 -0
  132. package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  133. package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  134. package/docs/adr/0003-git-guard-latch-writer.md +203 -0
  135. package/docs/adr/0004-disposition-policy-seam.md +38 -0
  136. package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  137. package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  138. package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  139. package/docs/adr/0008-turn-end-lane-interface.md +165 -0
  140. package/docs/adr/0009-reported-path-attribution.md +114 -0
  141. package/docs/adr/README.md +26 -0
  142. package/docs/agent-guide.md +13 -4
  143. package/docs/agent-tools.md +60 -1
  144. package/docs/ast-grep_rules_catalog.md +6 -4
  145. package/docs/configuration.md +75 -2
  146. package/docs/engineering-principles.md +202 -0
  147. package/docs/environment-variables.md +113 -6
  148. package/docs/features.md +10 -6
  149. package/docs/globalconfig.md +7 -1
  150. package/docs/io-bridge-v2-migration.md +94 -0
  151. package/docs/language-coverage.md +17 -0
  152. package/docs/lsp-capability-matrix.md +131 -32
  153. package/docs/lsp-idle-eviction.md +124 -0
  154. package/docs/mcp.md +21 -5
  155. package/docs/merge-queue-rollout.md +77 -0
  156. package/docs/pi-lens-fixer.md +161 -39
  157. package/docs/pi-lens-investigator.md +77 -20
  158. package/docs/pi-lens-merge-policy.md +694 -0
  159. package/docs/pi-lens-monitor.md +73 -7
  160. package/docs/pi-lens-retro.md +6 -4
  161. package/docs/pi-lens-reviewer.md +190 -34
  162. package/docs/pi-lens-subagent.md +247 -37
  163. package/docs/pi-lens-warden.md +18 -7
  164. package/docs/public-api-stability.md +56 -0
  165. package/docs/release-qa-baseline.md +86 -17
  166. package/docs/rfcs/3654-unified-io-bridge-v2.md +316 -0
  167. package/docs/servercapabilities.md +66 -52
  168. package/docs/settings.md +13 -3
  169. package/docs/subagent-compat.md +1 -1
  170. package/docs/usage.md +5 -1
  171. package/grammars/tree-sitter-bash.wasm +0 -0
  172. package/grammars/tree-sitter-bash.wasm.json +3 -3
  173. package/package.json +23 -9
  174. package/rules/ast-grep-rules/rule-tests/advisory-registry-subset-test.yml +41 -0
  175. package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  176. package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  177. package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  178. package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  179. package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  180. package/rules/ast-grep-rules/rules/advisory-registry-subset.yml +50 -0
  181. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  182. package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  183. package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  184. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  185. package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  186. package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  187. package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  188. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  189. package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  190. package/scripts/analyze-pi-lens-logs.mjs +1275 -62
  191. package/scripts/download-grammars.js +5 -0
  192. package/scripts/grammars.lock.json +6 -1
  193. package/scripts/install-selftest.mjs +38 -35
  194. package/scripts/lib/host-provided-deps.mjs +13 -4
  195. package/scripts/lib/web-tree-sitter-dir.mjs +141 -0
  196. package/dist/clients/actionable-warnings-logger.js +0 -35
  197. package/dist/clients/actionable-warnings.js +0 -1594
  198. package/dist/clients/advisory-provenance.js +0 -457
  199. package/dist/clients/agent-behavior-client.js +0 -147
  200. package/dist/clients/agent-nudge.js +0 -325
  201. package/dist/clients/analysed-root.js +0 -1
  202. package/dist/clients/ast-grep-client.js +0 -731
  203. package/dist/clients/ast-grep-rule-manager.js +0 -146
  204. package/dist/clients/ast-grep-tool-logger.js +0 -118
  205. package/dist/clients/ast-grep-types.js +0 -10
  206. package/dist/clients/ast-grep-yaml-synth.js +0 -154
  207. package/dist/clients/atomic-write-staging.js +0 -75
  208. package/dist/clients/atomic-write.js +0 -189
  209. package/dist/clients/availability-probe-flight.js +0 -12
  210. package/dist/clients/bash-file-access.js +0 -1056
  211. package/dist/clients/biome-client.js +0 -357
  212. package/dist/clients/blocker-freshness.js +0 -837
  213. package/dist/clients/blocker-past-eof.js +0 -129
  214. package/dist/clients/bootstrap.js +0 -569
  215. package/dist/clients/bounded-cache.js +0 -190
  216. package/dist/clients/bounded-pid-file-lock.js +0 -202
  217. package/dist/clients/bounded-telemetry.js +0 -295
  218. package/dist/clients/build-identity.js +0 -95
  219. package/dist/clients/bundled-resource-health.js +0 -113
  220. package/dist/clients/bus-events-logger.js +0 -105
  221. package/dist/clients/bus-publish.js +0 -171
  222. package/dist/clients/cache/rule-cache.js +0 -190
  223. package/dist/clients/cache-manager.js +0 -465
  224. package/dist/clients/cache-observability.js +0 -1313
  225. package/dist/clients/call-graph.js +0 -852
  226. package/dist/clients/cargo-manifest.js +0 -422
  227. package/dist/clients/cascade-budget.js +0 -196
  228. package/dist/clients/cascade-format.js +0 -315
  229. package/dist/clients/cascade-logger.js +0 -31
  230. package/dist/clients/cascade-types.js +0 -9
  231. package/dist/clients/child-unref.js +0 -115
  232. package/dist/clients/code-quality-warnings.js +0 -286
  233. package/dist/clients/codebase-model.js +0 -173
  234. package/dist/clients/collateral-test-role.js +0 -40
  235. package/dist/clients/complexity-client.js +0 -462
  236. package/dist/clients/config-core/deny.js +0 -221
  237. package/dist/clients/config-core/index.js +0 -50
  238. package/dist/clients/config-core/merge.js +0 -357
  239. package/dist/clients/config-core/normalize.js +0 -340
  240. package/dist/clients/config-core/process-spec.js +0 -248
  241. package/dist/clients/config-core/provenance.js +0 -164
  242. package/dist/clients/config-core/records.js +0 -218
  243. package/dist/clients/config-core/resolve.js +0 -125
  244. package/dist/clients/config-core/safe-object.js +0 -78
  245. package/dist/clients/config-core/schema.js +0 -165
  246. package/dist/clients/config-diagnostic-codes.js +0 -283
  247. package/dist/clients/config-locations.js +0 -160
  248. package/dist/clients/config-resolve.js +0 -792
  249. package/dist/clients/config-schema.js +0 -251
  250. package/dist/clients/config-warn.js +0 -407
  251. package/dist/clients/console-guard-install.js +0 -26
  252. package/dist/clients/cooperative-budget.js +0 -42
  253. package/dist/clients/dead-code-client.js +0 -510
  254. package/dist/clients/dead-code-logger.js +0 -30
  255. package/dist/clients/deadline-utils.js +0 -280
  256. package/dist/clients/debug-handles.js +0 -211
  257. package/dist/clients/debug-heap.js +0 -174
  258. package/dist/clients/deferred-lsp-work.js +0 -106
  259. package/dist/clients/degradation-ledger.js +0 -396
  260. package/dist/clients/demoted-finding-render.js +0 -169
  261. package/dist/clients/dependency-checker.js +0 -862
  262. package/dist/clients/deps/ast-grep-napi.js +0 -22
  263. package/dist/clients/deps/js-yaml.js +0 -9
  264. package/dist/clients/deps/minimatch.js +0 -4
  265. package/dist/clients/deps/pi-tui.js +0 -14
  266. package/dist/clients/deps/typebox.js +0 -14
  267. package/dist/clients/deps/vscode-jsonrpc.js +0 -1
  268. package/dist/clients/deps/web-tree-sitter.js +0 -24
  269. package/dist/clients/diagnostic-dispositions.js +0 -594
  270. package/dist/clients/diagnostic-line-freshness.js +0 -305
  271. package/dist/clients/diagnostic-logger.js +0 -73
  272. package/dist/clients/diagnostic-tracker.js +0 -105
  273. package/dist/clients/diagnostics-publish.js +0 -230
  274. package/dist/clients/dispatch/ast-grep-catalog.js +0 -65
  275. package/dist/clients/dispatch/auxiliary-lsp.js +0 -343
  276. package/dist/clients/dispatch/collect-later-tier.js +0 -35
  277. package/dist/clients/dispatch/diagnostic-taxonomy.js +0 -66
  278. package/dist/clients/dispatch/dispatcher.js +0 -1269
  279. package/dist/clients/dispatch/fact-provider-types.js +0 -1
  280. package/dist/clients/dispatch/fact-rule-runner.js +0 -17
  281. package/dist/clients/dispatch/fact-runner.js +0 -30
  282. package/dist/clients/dispatch/fact-scheduler.js +0 -68
  283. package/dist/clients/dispatch/fact-store.js +0 -377
  284. package/dist/clients/dispatch/facts/comment-facts.js +0 -22
  285. package/dist/clients/dispatch/facts/file-content.js +0 -19
  286. package/dist/clients/dispatch/facts/function-facts.js +0 -348
  287. package/dist/clients/dispatch/facts/import-facts.js +0 -204
  288. package/dist/clients/dispatch/facts/tree-sitter-facts.js +0 -34
  289. package/dist/clients/dispatch/facts/try-catch-facts.js +0 -119
  290. package/dist/clients/dispatch/finding-policy.js +0 -222
  291. package/dist/clients/dispatch/indent-detect.js +0 -411
  292. package/dist/clients/dispatch/inline-suppressions.js +0 -70
  293. package/dist/clients/dispatch/integration.js +0 -2377
  294. package/dist/clients/dispatch/lazy.js +0 -11
  295. package/dist/clients/dispatch/pending-runner-findings.js +0 -87
  296. package/dist/clients/dispatch/plan.js +0 -278
  297. package/dist/clients/dispatch/priorities.js +0 -20
  298. package/dist/clients/dispatch/rule-id-normalize.js +0 -50
  299. package/dist/clients/dispatch/rule-ignores.js +0 -62
  300. package/dist/clients/dispatch/rule-policy.js +0 -135
  301. package/dist/clients/dispatch/rules/async-noise.js +0 -40
  302. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +0 -27
  303. package/dist/clients/dispatch/rules/cors-wildcard.js +0 -54
  304. package/dist/clients/dispatch/rules/error-obscuring.js +0 -32
  305. package/dist/clients/dispatch/rules/error-swallowing.js +0 -31
  306. package/dist/clients/dispatch/rules/framework-call-noise.js +0 -67
  307. package/dist/clients/dispatch/rules/high-complexity.js +0 -72
  308. package/dist/clients/dispatch/rules/high-fan-out.js +0 -74
  309. package/dist/clients/dispatch/rules/high-import-coupling.js +0 -37
  310. package/dist/clients/dispatch/rules/missing-error-propagation.js +0 -62
  311. package/dist/clients/dispatch/rules/no-commented-credentials.js +0 -52
  312. package/dist/clients/dispatch/rules/pass-through-wrappers.js +0 -42
  313. package/dist/clients/dispatch/rules/placeholder-comments.js +0 -40
  314. package/dist/clients/dispatch/rules/unsafe-boundary.js +0 -126
  315. package/dist/clients/dispatch/runner-context.js +0 -52
  316. package/dist/clients/dispatch/runners/actionlint.js +0 -113
  317. package/dist/clients/dispatch/runners/ast-grep-napi.js +0 -1196
  318. package/dist/clients/dispatch/runners/biome-check.js +0 -283
  319. package/dist/clients/dispatch/runners/cpp-check.js +0 -236
  320. package/dist/clients/dispatch/runners/credo.js +0 -80
  321. package/dist/clients/dispatch/runners/cue-vet.js +0 -398
  322. package/dist/clients/dispatch/runners/dart-analyze.js +0 -202
  323. package/dist/clients/dispatch/runners/detekt.js +0 -169
  324. package/dist/clients/dispatch/runners/dotnet-build.js +0 -159
  325. package/dist/clients/dispatch/runners/elixir-check.js +0 -210
  326. package/dist/clients/dispatch/runners/eslint.js +0 -154
  327. package/dist/clients/dispatch/runners/fact-rules.js +0 -29
  328. package/dist/clients/dispatch/runners/fish-indent.js +0 -69
  329. package/dist/clients/dispatch/runners/gleam-check.js +0 -87
  330. package/dist/clients/dispatch/runners/go-vet.js +0 -90
  331. package/dist/clients/dispatch/runners/golangci-lint.js +0 -120
  332. package/dist/clients/dispatch/runners/hadolint.js +0 -79
  333. package/dist/clients/dispatch/runners/helm-lint.js +0 -171
  334. package/dist/clients/dispatch/runners/helm-render.js +0 -940
  335. package/dist/clients/dispatch/runners/htmlhint.js +0 -108
  336. package/dist/clients/dispatch/runners/index.js +0 -108
  337. package/dist/clients/dispatch/runners/javac.js +0 -89
  338. package/dist/clients/dispatch/runners/ktlint.js +0 -136
  339. package/dist/clients/dispatch/runners/lsp.js +0 -378
  340. package/dist/clients/dispatch/runners/markdownlint.js +0 -160
  341. package/dist/clients/dispatch/runners/mypy.js +0 -101
  342. package/dist/clients/dispatch/runners/oxlint.js +0 -398
  343. package/dist/clients/dispatch/runners/php-lint.js +0 -62
  344. package/dist/clients/dispatch/runners/phpstan.js +0 -120
  345. package/dist/clients/dispatch/runners/prisma-validate.js +0 -62
  346. package/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -445
  347. package/dist/clients/dispatch/runners/pyright.js +0 -169
  348. package/dist/clients/dispatch/runners/rubocop.js +0 -90
  349. package/dist/clients/dispatch/runners/ruff.js +0 -89
  350. package/dist/clients/dispatch/runners/rust-clippy.js +0 -251
  351. package/dist/clients/dispatch/runners/shellcheck.js +0 -169
  352. package/dist/clients/dispatch/runners/shfmt.js +0 -102
  353. package/dist/clients/dispatch/runners/spellcheck.js +0 -121
  354. package/dist/clients/dispatch/runners/spotbugs.js +0 -259
  355. package/dist/clients/dispatch/runners/sqlfluff.js +0 -174
  356. package/dist/clients/dispatch/runners/stylelint.js +0 -158
  357. package/dist/clients/dispatch/runners/swiftlint.js +0 -154
  358. package/dist/clients/dispatch/runners/taplo.js +0 -168
  359. package/dist/clients/dispatch/runners/terragrunt.js +0 -168
  360. package/dist/clients/dispatch/runners/tflint.js +0 -110
  361. package/dist/clients/dispatch/runners/tree-sitter.js +0 -620
  362. package/dist/clients/dispatch/runners/trivy-config.js +0 -247
  363. package/dist/clients/dispatch/runners/utils/availability-policy.js +0 -496
  364. package/dist/clients/dispatch/runners/utils/candidate-probe.js +0 -90
  365. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +0 -85
  366. package/dist/clients/dispatch/runners/utils/lazy-installer.js +0 -274
  367. package/dist/clients/dispatch/runners/utils/runner-helpers.js +0 -1701
  368. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +0 -101
  369. package/dist/clients/dispatch/runners/utils/tool-failure.js +0 -235
  370. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +0 -169
  371. package/dist/clients/dispatch/runners/utils.js +0 -15
  372. package/dist/clients/dispatch/runners/vale.js +0 -116
  373. package/dist/clients/dispatch/runners/yaml-rule-parser.js +0 -242
  374. package/dist/clients/dispatch/runners/yamllint.js +0 -85
  375. package/dist/clients/dispatch/runners/zig-check.js +0 -91
  376. package/dist/clients/dispatch/suppress-writer.js +0 -69
  377. package/dist/clients/dispatch/tool-profile.js +0 -30
  378. package/dist/clients/dispatch/types.js +0 -21
  379. package/dist/clients/dispatch/utils/format-utils.js +0 -45
  380. package/dist/clients/dispatch/utils/lsp-diagnostics.js +0 -35
  381. package/dist/clients/disposition-logger.js +0 -32
  382. package/dist/clients/disposition-publish.js +0 -124
  383. package/dist/clients/durable-store.js +0 -97
  384. package/dist/clients/effective-config.js +0 -413
  385. package/dist/clients/env-utils.js +0 -83
  386. package/dist/clients/error-class.js +0 -23
  387. package/dist/clients/eval-timestamp.js +0 -17
  388. package/dist/clients/event-loop-hold.js +0 -274
  389. package/dist/clients/event-loop-monitor.js +0 -250
  390. package/dist/clients/extension-log.js +0 -504
  391. package/dist/clients/extension-mode.js +0 -73
  392. package/dist/clients/feature-hints.js +0 -103
  393. package/dist/clients/file-kinds.js +0 -412
  394. package/dist/clients/file-role.js +0 -134
  395. package/dist/clients/file-time.js +0 -160
  396. package/dist/clients/file-utils.js +0 -1032
  397. package/dist/clients/finding-delivery-gate.js +0 -463
  398. package/dist/clients/finding-identity.js +0 -88
  399. package/dist/clients/fix-worklog.js +0 -103
  400. package/dist/clients/format-events-publish.js +0 -343
  401. package/dist/clients/format-service.js +0 -222
  402. package/dist/clients/formatters-lazy.js +0 -9
  403. package/dist/clients/formatters.js +0 -1994
  404. package/dist/clients/freshness-cadence.js +0 -17
  405. package/dist/clients/freshness.js +0 -38
  406. package/dist/clients/generated-artifacts.js +0 -394
  407. package/dist/clients/generation-guard.js +0 -215
  408. package/dist/clients/git-guard.js +0 -923
  409. package/dist/clients/git-tracked-ignore.js +0 -243
  410. package/dist/clients/gitleaks-client.js +0 -509
  411. package/dist/clients/go-client.js +0 -104
  412. package/dist/clients/govulncheck-client.js +0 -605
  413. package/dist/clients/gradle-ktfmt-style.js +0 -252
  414. package/dist/clients/grammar-source.js +0 -465
  415. package/dist/clients/gzip-stage-write.js +0 -135
  416. package/dist/clients/hashline-anchor.js +0 -424
  417. package/dist/clients/hook-budgets.js +0 -76
  418. package/dist/clients/host-edit-normalize.js +0 -140
  419. package/dist/clients/host-ports.js +0 -39
  420. package/dist/clients/indent-retarget.js +0 -154
  421. package/dist/clients/install-diagnostics.js +0 -143
  422. package/dist/clients/installer/index.js +0 -5167
  423. package/dist/clients/installer/managed-tool-refresh-session.js +0 -58
  424. package/dist/clients/installer/managed-tool-refresh.js +0 -783
  425. package/dist/clients/instance-reaper-state.js +0 -7
  426. package/dist/clients/instance-reaper.js +0 -1137
  427. package/dist/clients/instance-registry-lock.js +0 -145
  428. package/dist/clients/instance-registry.js +0 -773
  429. package/dist/clients/jscpd-client.js +0 -370
  430. package/dist/clients/json-cache-read.js +0 -65
  431. package/dist/clients/knip-client.js +0 -788
  432. package/dist/clients/language-policy.js +0 -252
  433. package/dist/clients/language-profile.js +0 -361
  434. package/dist/clients/language-registry.js +0 -603
  435. package/dist/clients/latency-logger.js +0 -719
  436. package/dist/clients/lazy-import.js +0 -21
  437. package/dist/clients/ledger-bounds.js +0 -54
  438. package/dist/clients/lens-config.js +0 -346
  439. package/dist/clients/lens-engine.js +0 -358
  440. package/dist/clients/lens-events.js +0 -125
  441. package/dist/clients/lens-flag-registry.js +0 -467
  442. package/dist/clients/lens-map.js +0 -1156
  443. package/dist/clients/live-bus-emitter.js +0 -74
  444. package/dist/clients/log-cleanup.js +0 -222
  445. package/dist/clients/lsp/aggregation.js +0 -178
  446. package/dist/clients/lsp/cascade-tier.js +0 -421
  447. package/dist/clients/lsp/client.js +0 -4465
  448. package/dist/clients/lsp/config.js +0 -689
  449. package/dist/clients/lsp/diagnostic-binding.js +0 -225
  450. package/dist/clients/lsp/document-drift.js +0 -467
  451. package/dist/clients/lsp/edits.js +0 -1219
  452. package/dist/clients/lsp/index.js +0 -8305
  453. package/dist/clients/lsp/inferred-project.js +0 -216
  454. package/dist/clients/lsp/jvm-runtime.js +0 -298
  455. package/dist/clients/lsp/language.js +0 -63
  456. package/dist/clients/lsp/launch.js +0 -725
  457. package/dist/clients/lsp/lombok.js +0 -158
  458. package/dist/clients/lsp/path-utils.js +0 -5
  459. package/dist/clients/lsp/pending-aux-coverage.js +0 -251
  460. package/dist/clients/lsp/position-encoding.js +0 -61
  461. package/dist/clients/lsp/ruby-drive-dirs.js +0 -83
  462. package/dist/clients/lsp/server.js +0 -3209
  463. package/dist/clients/lsp/session-roots.js +0 -192
  464. package/dist/clients/lsp/spawn-history.js +0 -17
  465. package/dist/clients/lsp/sync-kind.js +0 -42
  466. package/dist/clients/lsp/tsserver-sync.js +0 -332
  467. package/dist/clients/lsp/wait-policy/capability-snapshot.js +0 -1
  468. package/dist/clients/lsp/wait-policy/classification.js +0 -85
  469. package/dist/clients/lsp/wait-policy/index.js +0 -10
  470. package/dist/clients/lsp/wait-policy/strategies.js +0 -303
  471. package/dist/clients/lsp/watch-queue.js +0 -61
  472. package/dist/clients/lsp/workspace-diagnostics-cache.js +0 -639
  473. package/dist/clients/lsp/workspace-diagnostics-session.js +0 -30
  474. package/dist/clients/lsp/workspace-sweep-hold.js +0 -184
  475. package/dist/clients/lsp-budget.js +0 -186
  476. package/dist/clients/lsp-document-symbols.js +0 -110
  477. package/dist/clients/lsp-lazy.js +0 -9
  478. package/dist/clients/lsp-mutation.js +0 -396
  479. package/dist/clients/lsp-status.js +0 -59
  480. package/dist/clients/map-with-concurrency.js +0 -40
  481. package/dist/clients/mcp/analyze.js +0 -420
  482. package/dist/clients/mcp/host-shim.js +0 -31
  483. package/dist/clients/mcp/ipc.js +0 -335
  484. package/dist/clients/mcp/review.js +0 -151
  485. package/dist/clients/mcp/session.js +0 -412
  486. package/dist/clients/memory-sampler.js +0 -208
  487. package/dist/clients/message-end-attribution.js +0 -30
  488. package/dist/clients/metrics-client.js +0 -107
  489. package/dist/clients/metrics-history.js +0 -327
  490. package/dist/clients/middle-man-analysis.js +0 -263
  491. package/dist/clients/model-provider.js +0 -53
  492. package/dist/clients/module-report-lsp.js +0 -189
  493. package/dist/clients/module-report.js +0 -2150
  494. package/dist/clients/mutating-tool.js +0 -651
  495. package/dist/clients/mutation-attribution.js +0 -368
  496. package/dist/clients/mutation-bridge.js +0 -240
  497. package/dist/clients/ndjson-logger.js +0 -734
  498. package/dist/clients/observed-mutation-sources.js +0 -101
  499. package/dist/clients/observed-mutation.js +0 -1253
  500. package/dist/clients/oldtext-autopatch.js +0 -128
  501. package/dist/clients/opaque-mutation-scan.js +0 -496
  502. package/dist/clients/opengrep-client.js +0 -328
  503. package/dist/clients/opengrep-config.js +0 -76
  504. package/dist/clients/package-manager.js +0 -607
  505. package/dist/clients/package-root.js +0 -38
  506. package/dist/clients/partial-edit-apply.js +0 -439
  507. package/dist/clients/path-attribution-telemetry.js +0 -43
  508. package/dist/clients/path-keyed-map.js +0 -108
  509. package/dist/clients/path-utils.js +0 -1244
  510. package/dist/clients/performance-report.js +0 -331
  511. package/dist/clients/persist-debounce.js +0 -70
  512. package/dist/clients/persistent-reverify.js +0 -271
  513. package/dist/clients/php-cs-fixer-config.js +0 -119
  514. package/dist/clients/pipeline.js +0 -1425
  515. package/dist/clients/post-autofix-notice.js +0 -37
  516. package/dist/clients/print-mode.js +0 -21
  517. package/dist/clients/probe-home-state.js +0 -228
  518. package/dist/clients/process-bridge.js +0 -66
  519. package/dist/clients/process-singletons.js +0 -210
  520. package/dist/clients/process-snapshot.js +0 -68
  521. package/dist/clients/project-changes.js +0 -181
  522. package/dist/clients/project-conventions.js +0 -171
  523. package/dist/clients/project-diagnostics/cache.js +0 -137
  524. package/dist/clients/project-diagnostics/extractors.js +0 -109
  525. package/dist/clients/project-diagnostics/fresh-fetch.js +0 -650
  526. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +0 -118
  527. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +0 -47
  528. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +0 -40
  529. package/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +0 -34
  530. package/dist/clients/project-diagnostics/runner-adapters/jscpd.js +0 -38
  531. package/dist/clients/project-diagnostics/runner-adapters/knip.js +0 -47
  532. package/dist/clients/project-diagnostics/runner-adapters/madge.js +0 -48
  533. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +0 -40
  534. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +0 -127
  535. package/dist/clients/project-diagnostics/runner-adapters/trivy.js +0 -81
  536. package/dist/clients/project-diagnostics/scanner.js +0 -520
  537. package/dist/clients/project-diagnostics/types.js +0 -1
  538. package/dist/clients/project-lens-config.js +0 -799
  539. package/dist/clients/project-report.js +0 -701
  540. package/dist/clients/project-scale.js +0 -237
  541. package/dist/clients/project-scan-policy.js +0 -11
  542. package/dist/clients/project-snapshot-fingerprint.js +0 -48
  543. package/dist/clients/project-snapshot-persist-worker.js +0 -20
  544. package/dist/clients/project-snapshot.js +0 -1653
  545. package/dist/clients/project-trust.js +0 -181
  546. package/dist/clients/python-environment.js +0 -217
  547. package/dist/clients/python-provenance.js +0 -639
  548. package/dist/clients/quiet-window-config.js +0 -47
  549. package/dist/clients/quiet-window.js +0 -236
  550. package/dist/clients/read-bridge.js +0 -139
  551. package/dist/clients/read-expansion.js +0 -266
  552. package/dist/clients/read-guard-logger.js +0 -251
  553. package/dist/clients/read-guard-tool-lines.js +0 -1306
  554. package/dist/clients/read-guard.js +0 -1561
  555. package/dist/clients/recent-touches.js +0 -240
  556. package/dist/clients/redact/secrets.js +0 -449
  557. package/dist/clients/resource-sampler.js +0 -628
  558. package/dist/clients/reverse-deps.js +0 -270
  559. package/dist/clients/review-graph/builder.js +0 -5118
  560. package/dist/clients/review-graph/format.js +0 -33
  561. package/dist/clients/review-graph/git-identity.js +0 -157
  562. package/dist/clients/review-graph/import-resolvers.js +0 -481
  563. package/dist/clients/review-graph/persist-worker.js +0 -10
  564. package/dist/clients/review-graph/query.js +0 -244
  565. package/dist/clients/review-graph/revision-drift.js +0 -21
  566. package/dist/clients/review-graph/service.js +0 -96
  567. package/dist/clients/review-graph/shared-extraction-ir.js +0 -82
  568. package/dist/clients/review-graph/symbol-id.js +0 -103
  569. package/dist/clients/review-graph/tsconfig-paths.js +0 -299
  570. package/dist/clients/review-graph/types.js +0 -1
  571. package/dist/clients/review-graph/workspace-modules.js +0 -449
  572. package/dist/clients/review-graph-logger.js +0 -81
  573. package/dist/clients/ruff-client.js +0 -152
  574. package/dist/clients/rules-scanner.js +0 -131
  575. package/dist/clients/run-duration.js +0 -55
  576. package/dist/clients/runtime-agent-end.js +0 -746
  577. package/dist/clients/runtime-config.js +0 -77
  578. package/dist/clients/runtime-context.js +0 -211
  579. package/dist/clients/runtime-coordinator.js +0 -1290
  580. package/dist/clients/runtime-session.js +0 -2485
  581. package/dist/clients/runtime-tool-call.js +0 -1284
  582. package/dist/clients/runtime-tool-result.js +0 -2034
  583. package/dist/clients/runtime-turn.js +0 -3600
  584. package/dist/clients/rust-client.js +0 -97
  585. package/dist/clients/safe-spawn.js +0 -2166
  586. package/dist/clients/sanitize.js +0 -286
  587. package/dist/clients/scan-utils.js +0 -25
  588. package/dist/clients/scratch-tree-policy.js +0 -165
  589. package/dist/clients/search-read-registration.js +0 -94
  590. package/dist/clients/secret-findings.js +0 -114
  591. package/dist/clients/security-scan-client.js +0 -352
  592. package/dist/clients/session-event-guard.js +0 -297
  593. package/dist/clients/session-lifecycle.js +0 -484
  594. package/dist/clients/session-start-observability.js +0 -94
  595. package/dist/clients/session-state-store.js +0 -101
  596. package/dist/clients/session-summary.js +0 -15
  597. package/dist/clients/sessionstart-logger.js +0 -27
  598. package/dist/clients/sg-runner.js +0 -876
  599. package/dist/clients/sgconfig.js +0 -347
  600. package/dist/clients/shared-checkout-guard.js +0 -338
  601. package/dist/clients/single-flight.js +0 -158
  602. package/dist/clients/situational-tool-telemetry.js +0 -108
  603. package/dist/clients/skills-resolver.js +0 -141
  604. package/dist/clients/slow-fs.js +0 -138
  605. package/dist/clients/smells-rollup.js +0 -231
  606. package/dist/clients/source-filter.js +0 -596
  607. package/dist/clients/source-walker.js +0 -211
  608. package/dist/clients/spawn-output-cap.js +0 -37
  609. package/dist/clients/spawn-timeout-cooldown.js +0 -110
  610. package/dist/clients/stale-marker.js +0 -17
  611. package/dist/clients/startup-scan.js +0 -400
  612. package/dist/clients/startup-timing.js +0 -130
  613. package/dist/clients/string-utils.js +0 -33
  614. package/dist/clients/subagent-mode.js +0 -111
  615. package/dist/clients/symbol-containment.js +0 -24
  616. package/dist/clients/symbol-types.js +0 -5
  617. package/dist/clients/test-runner-client.js +0 -2507
  618. package/dist/clients/test-runner-delivery.js +0 -287
  619. package/dist/clients/todo-scanner.js +0 -229
  620. package/dist/clients/tool-agreement.js +0 -423
  621. package/dist/clients/tool-config.js +0 -249
  622. package/dist/clients/tool-cwd.js +0 -270
  623. package/dist/clients/tool-definition.js +0 -47
  624. package/dist/clients/tool-event.js +0 -105
  625. package/dist/clients/tool-policy.js +0 -2755
  626. package/dist/clients/tool-probe.js +0 -53
  627. package/dist/clients/tool-render.js +0 -156
  628. package/dist/clients/tool-set-policy.js +0 -118
  629. package/dist/clients/tree-sitter-cache.js +0 -364
  630. package/dist/clients/tree-sitter-client.js +0 -4134
  631. package/dist/clients/tree-sitter-logger.js +0 -100
  632. package/dist/clients/tree-sitter-navigator.js +0 -269
  633. package/dist/clients/tree-sitter-query-loader.js +0 -569
  634. package/dist/clients/tree-sitter-shared.js +0 -158
  635. package/dist/clients/tree-sitter-symbol-extractor.js +0 -1265
  636. package/dist/clients/trivy-client.js +0 -467
  637. package/dist/clients/tui-fit.js +0 -50
  638. package/dist/clients/turn-context.js +0 -52
  639. package/dist/clients/turn-summary-render.js +0 -72
  640. package/dist/clients/turn-summary.js +0 -132
  641. package/dist/clients/typos-config.js +0 -29
  642. package/dist/clients/user-notify.js +0 -51
  643. package/dist/clients/vanished-instance-marker.js +0 -57
  644. package/dist/clients/warm-attach.js +0 -246
  645. package/dist/clients/widget-state.js +0 -1677
  646. package/dist/clients/word-index-logger.js +0 -49
  647. package/dist/clients/word-index-store.js +0 -459
  648. package/dist/clients/word-index.js +0 -2080
  649. package/dist/clients/workspace-topology.js +0 -389
  650. package/dist/clients/write-ordering-guard.js +0 -65
  651. package/dist/clients/zizmor-config.js +0 -327
  652. package/dist/i18n.js +0 -69
  653. package/dist/mcp/build-staleness.js +0 -124
  654. package/dist/scripts/lib/process-scan.mjs +0 -583
  655. package/dist/scripts/lib/skills-predicate.mjs +0 -129
  656. package/dist/tools/activate-tools.js +0 -87
  657. package/dist/tools/ast-grep-outline.js +0 -137
  658. package/dist/tools/ast-grep-replace.js +0 -244
  659. package/dist/tools/ast-grep-search.js +0 -762
  660. package/dist/tools/effective-config.js +0 -90
  661. package/dist/tools/lens-diagnostic-mark.js +0 -375
  662. package/dist/tools/lens-diagnostics.js +0 -2544
  663. package/dist/tools/lsp-diagnostics.js +0 -1634
  664. package/dist/tools/lsp-navigation.js +0 -1367
  665. package/dist/tools/lsp-structured-output.js +0 -294
  666. package/dist/tools/module-report.js +0 -382
  667. package/dist/tools/project-report.js +0 -86
  668. package/dist/tools/render-compact.js +0 -374
  669. package/dist/tools/scan-progress.js +0 -53
  670. package/dist/tools/shared.js +0 -29
  671. package/dist/tools/symbol-search.js +0 -155
@@ -1,3600 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { buildActionableWarningsReport, formatActionableWarningsAdvisory, publishActionableWarningsReport, writeDeferredActionableWarningsReport, } from "./actionable-warnings.js";
4
- import { logActionableWarningsEvent } from "./actionable-warnings-logger.js";
5
- import { appendCodeQualityWarningsHistory, buildCodeQualityWarningsReport, formatCodeQualityWarningsAdvisory, writeCodeQualityWarningsReport, } from "./code-quality-warnings.js";
6
- import { clearGitGuardTestFailure, mergeGitGuardTestFailure, writeGitGuardRecord, } from "./git-guard.js";
7
- import { cascadeSettleWaitMs } from "./cascade-budget.js";
8
- import { logCascade } from "./cascade-logger.js";
9
- import { normalizeMapKey } from "./path-utils.js";
10
- import { compareOrdinal } from "./string-utils.js";
11
- import { resolveRunnerPath, toRunnerDisplayPath, } from "./dispatch/runner-context.js";
12
- import { getKnipIgnorePatterns } from "./file-utils.js";
13
- import { formatCacheAgeLabel } from "./finding-delivery-gate.js";
14
- import { getFullScanWallClockMs, isWorkspaceSweepActive, runWhenWorkspaceSweepIdle, SWEEP_IDLE_SAFETY_MARGIN_MS, } from "./lsp/workspace-sweep-hold.js";
15
- import { isTestRoleCollateral } from "./collateral-test-role.js";
16
- import { classifyAndFilterFindings, } from "./gitleaks-client.js";
17
- import { dedupeSecretFindings, fromAstGrepWarnings, fromGitleaks, fromTrivySecrets, isSecretWarning, secretLocationKey, } from "./secret-findings.js";
18
- import { deadCodeIssueKey, deadCodeIssues, formatDeadCodeDelta, stableFindingKey, } from "./dead-code-client.js";
19
- import { logDeadCodeScan } from "./dead-code-logger.js";
20
- import { PROJECT_DIAGNOSTICS_CACHE_VERSION, writeProjectDiagnosticsDeltaReport, } from "./project-diagnostics/cache.js";
21
- import { deadCodeIssueToProjectDiagnostic } from "./project-diagnostics/runner-adapters/dead-code.js";
22
- import { gitleaksFindingToProjectDiagnostic } from "./project-diagnostics/runner-adapters/gitleaks.js";
23
- import { govulncheckFindingToProjectDiagnostic } from "./project-diagnostics/runner-adapters/govulncheck.js";
24
- import { trivyFindingToProjectDiagnostic, trivySecretFindingToProjectDiagnostic, } from "./project-diagnostics/runner-adapters/trivy.js";
25
- import { knipIssuesToProjectDiagnostics } from "./project-diagnostics/runner-adapters/knip.js";
26
- import { applyDispositionsMultiFile } from "./diagnostic-dispositions.js";
27
- import { logLatency } from "./latency-logger.js";
28
- import { getLspBudgetIdleTimeoutMs, shouldShortenLspIdleTimeout, } from "./lsp-budget.js";
29
- import { updateHeartbeat } from "./instance-registry.js";
30
- import { emitLensTurnFindings } from "./lens-events.js";
31
- import { RUNTIME_CONFIG } from "./runtime-config.js";
32
- import { isSubagentSession } from "./subagent-mode.js";
33
- import { formatRunDurationMs } from "./run-duration.js";
34
- import { isExcludedTestTarget, isRunnerErrorResult, RUNNERS, } from "./test-runner-client.js";
35
- import { MAX_ADVISORY_AFFECTED_FILES, gateFindingsByPathFreshness, snapshotAdvisoryProvenance, } from "./advisory-provenance.js";
36
- import { DEPENDENCY_DRIFT_MAX_DELIVERIES, sweepInlineBlockerFreshness, } from "./blocker-freshness.js";
37
- import { sweepInlineBlockerPastEof } from "./blocker-past-eof.js";
38
- // #2001/#2002: collect-later delivery for slow auxiliary LSP servers.
39
- import { getLSPService } from "./lsp/index.js";
40
- import { drainPendingAuxCapEvictedCount, drainPendingAuxiliaryCoverage, isPendingAuxiliaryPastRearmTtl, rearmPendingAuxiliaryCoverage, MAX_LATE_AUX_REARMS, pendingAuxiliaryCoverageSize, } from "./lsp/pending-aux-coverage.js";
41
- import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
42
- import { retagAuxiliaryDiagnostics } from "./dispatch/auxiliary-lsp.js";
43
- import { runPersistentReverify, } from "./persistent-reverify.js";
44
- import { cascadeCarrySuffix } from "./cascade-format.js";
45
- import { applyFindingPolicy, loadProjectRulePolicyMap, renderedRuleIdentities, } from "./dispatch/finding-policy.js";
46
- import { detectFileRole } from "./file-role.js";
47
- import { drainPendingRunnerFindings, dropStaleRunnerFindings, pendingRunnerFindingsSize, } from "./dispatch/pending-runner-findings.js";
48
- // #1631 review V2: moved to its own leaf module so a low-level store
49
- // (widget-state.ts) can use the marker without importing this orchestrator —
50
- // see clients/stale-marker.ts's doc comment.
51
- import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
52
- import { mapWithConcurrency } from "./map-with-concurrency.js";
53
- import { getAmbientAbortSignal } from "./safe-spawn.js";
54
- import { emitBounded } from "./bounded-telemetry.js";
55
- import { degradeDemotedFindingBody, formatDeliveryCapNote, formatRetirementNote, } from "./demoted-finding-render.js";
56
- import { STALE_LINE_MARKER } from "./stale-marker.js";
57
- import { getActiveSessionId } from "./session-lifecycle.js";
58
- import { bounded } from "./deadline-utils.js";
59
- import { HOOK_WALL_BUDGET_MS } from "./hook-budgets.js";
60
- import { drainRenderedDependencyDriftFilePaths, getWidgetBlockingFilesForSweep, incrementWidgetDependencyDriftDelivery, markWidgetFileBlockersStale, recordRunner, retireWidgetDependencyDriftBlockers, } from "./widget-state.js";
61
- /** Maximum detailed notify-stall coverage-gap rows emitted in one turn. */
62
- const LATE_AUX_COVERAGE_GAP_DETAIL_CAP_PER_TURN = 20;
63
- /**
64
- * #2504 — bounds on the per-turn test-runner fan-out.
65
- *
66
- * The reported turn fired 59 `vitest.cmd` spawns at once from a bare
67
- * `Promise.allSettled(targets.map(...))`. Each spawn carries its OWN 60 s
68
- * timeout (`test-runner-client.ts`), which bounds one target and says nothing
69
- * about the batch: 59 of them starved the event loop for the whole turn
70
- * (`cpuCoverageRatio 0.56`, 20 orphan-backstop escalations inside the storm).
71
- *
72
- * Three separate bounds, because they fail differently:
73
- * - CONCURRENCY caps how much CPU the batch can hold at one instant;
74
- * - TARGET COUNT caps how much work one turn may enqueue at all;
75
- * - the WALL BUDGET caps how long the batch may keep spawning, and is raced
76
- * against the ambient abort signal so a cancelled turn stops dispatching
77
- * immediately rather than at the next natural boundary.
78
- *
79
- * #2522: the batch budget shipped at 90 s in #2509 — well over the turn
80
- * itself, so a slow/hung batch could still hold up the agent for most of a
81
- * turn before the bound even engaged. Reconciled down to 20 s (one constant,
82
- * not a second budget alongside it): well under the turn, and still an order
83
- * of magnitude below the PER-TARGET 60 s cap in `test-runner-client.ts`, so
84
- * the batch bound is the one that actually fires first for a stuck target.
85
- *
86
- * #2522 review round 2: lowering the budget on its own made things WORSE,
87
- * because the "deferred to the next turn" this comment used to claim did not
88
- * exist. `runTestTargetsBounded` returned a skipped COUNT and threw the
89
- * identity of the unrun targets away, so a target slower than the budget
90
- * produced no result, no cache record and no output — every turn, forever —
91
- * while a PARTIAL batch fell through to the clean branch and was reported as
92
- * "all tests passed". The deferral is now a real mechanism:
93
- *
94
- * - the batch owns an `AbortController` that is handed to each target's spawn
95
- * (`TestRunRequest.signal` → `safeSpawnAsync`), so reaching the bound KILLS
96
- * what is in flight instead of leaving it to burn its own 60 s timeout and
97
- * then mutate a results array the caller has already consumed;
98
- * - the killed set plus the never-dispatched set is returned BY IDENTITY as
99
- * `deferred`, persisted on the `test-runner-findings` record as
100
- * `deferredTargets`, and dispatched FIRST on the next turn — ahead of
101
- * failed-first/related/self — under the same `TEST_RUNNER_MAX_TARGETS` cap;
102
- * - a batch whose `deferred` set is non-empty can never take the clean
103
- * branch, so an unfinished run is never reported as a green one and never
104
- * relaxes the `--lens-guard` commit gate.
105
- */
106
- export const TEST_RUNNER_BATCH_CONCURRENCY = 4;
107
- export const TEST_RUNNER_MAX_TARGETS = 12;
108
- export const TEST_RUNNER_BATCH_BUDGET_MS = 20_000;
109
- /**
110
- * How many consecutive turn-end batches a target may be cut out of before it
111
- * is retired from turn-end selection altogether.
112
- *
113
- * Without this the deferral is a LIVELOCK, and not hypothetically: this
114
- * repo's own `tests/index-integration.test.ts` — which turn-end resolves
115
- * through the `related` strategy whenever `clients/runtime-turn.ts` is
116
- * edited — takes 26.4 s, MORE than the entire 20 s batch budget on its own.
117
- * Deferring it puts it FIRST in the next batch, where it is cut again at
118
- * 20 s, and again, every turn, forever. A target that cannot fit the budget
119
- * is not a scheduling problem retrying can solve; it is reported once per
120
- * occurrence and dropped, so the turn stops paying 20 s for it.
121
- */
122
- export const TEST_RUNNER_MAX_DEFERRALS = 2;
123
- /**
124
- * Ceiling on how many entries either persisted target list may carry.
125
- *
126
- * #2522 review round 4, I1: a write may no longer destroy another session's
127
- * entries, so nothing in the write path prunes them any more — each session
128
- * that ever cut or retired a target in this project leaves its rows behind, on
129
- * a record read at every single turn_end. The bound is applied at the one
130
- * writer, on the whole list, and sheds FOREIGN rows first (the writer orders
131
- * this session's entries last), so it can never evict the entries this turn
132
- * depends on. Generous on purpose: it is a backstop against unbounded growth,
133
- * not a scheduling policy.
134
- */
135
- export const TEST_RUNNER_MAX_PERSISTED_TARGETS = 64;
136
- /**
137
- * Union two deferral sets by target identity, keeping the HIGHER attempt count
138
- * (#2522 review round 3, F3). Two overlapping batches can both be cut on the
139
- * same target; taking the lower count would let a target trade an attempt for
140
- * every overlap and never converge on `TEST_RUNNER_MAX_DEFERRALS`.
141
- *
142
- * Identity is (session, path), not path alone (#2522 review round 4, I1): two
143
- * sessions can each owe a run of the same file, and collapsing those into one
144
- * row makes the surviving row's `sessionId` decide whose deferral is honoured
145
- * and whose is silently dropped. The path half is keyed through
146
- * `normalizeMapKey` so `/`- and `\`-separated spellings are one entry
147
- * (AGENTS.md cross-form-path screen).
148
- */
149
- function deferralEntryKey(entry) {
150
- return `${entry.sessionId ?? ""}\u0000${normalizeMapKey(path.resolve(entry.testFile))}`;
151
- }
152
- function mergeDeferredTargets(existing, incoming) {
153
- const byKey = new Map();
154
- for (const entry of [...existing, ...incoming]) {
155
- const key = deferralEntryKey(entry);
156
- const prior = byKey.get(key);
157
- if (prior && (prior.attempts ?? 0) >= (entry.attempts ?? 0))
158
- continue;
159
- byKey.set(key, entry);
160
- }
161
- return [...byKey.values()];
162
- }
163
- /**
164
- * Run `run` over `targets` with a concurrency cap, a batch-wide wall budget
165
- * and an abort-signal race. Reuses `mapWithConcurrency` (the repo's existing
166
- * worker-pool shape) rather than hand-rolling a second pool.
167
- *
168
- * Both bounds are enforced twice on purpose: cooperatively, before each
169
- * dispatch, so no NEW work starts past the bound; and as a real race against
170
- * the pool, so an in-flight target cannot hold the batch past its budget.
171
- * Whatever has settled by then is returned — a partial batch of real results
172
- * is strictly better than none, and the caller reports the shortfall.
173
- */
174
- export async function runTestTargetsBounded(args) {
175
- const results = [];
176
- if (args.targets.length === 0)
177
- return { results, deferred: [] };
178
- let stopReason;
179
- // One controller for the whole batch. Aborting it tree-kills every child the
180
- // runner has in flight (`safeSpawnAsync` honours the signal it is handed),
181
- // which is what turns "the bound fired" into "the work actually stopped".
182
- const batchAbort = new AbortController();
183
- // Latched at the moment this call returns. Nothing may push into `results`
184
- // after that: the caller has already consumed the array, and a late push
185
- // would silently change a batch it has finished reasoning about.
186
- let closed = false;
187
- /**
188
- * #2522 review round 4, I4: "was this target CUT, or did it FINISH?" is a
189
- * semantic question, and this set is the answer — stamped by the `.then`
190
- * attached to `run`'s promise AT DISPATCH, which is that promise's FIRST
191
- * continuation. Nothing but a microtask can run between the promise settling
192
- * and this stamp, and every path that closes the batch (`setTimeout`, the
193
- * ambient signal's `abort` event, the pool settling after every mapper has
194
- * returned) reaches `finish()` from a macrotask or later. So `closed` read
195
- * here is exactly "the batch was already cut when this value existed".
196
- *
197
- * Round 3 read the same flag from the mapper's own `await` continuation,
198
- * which is several continuations downstream of the settle — a macrotask CAN
199
- * interleave there — and papered over the resulting misclassification with a
200
- * `setImmediate` hop plus a `latchedOut` parking array. Both are gone: the
201
- * hop made the answer depend on queue ordering, which is the thing that kept
202
- * producing findings (a spawn aborted BEFORE it started resolves
203
- * synchronously, so the hop folded it back in as a completed run — round 4
204
- * P3c).
205
- */
206
- const settledBeforeClose = new Set();
207
- const budgetMs = Math.max(0, args.budgetMs);
208
- const deadline = Date.now() + budgetMs;
209
- const shouldStop = () => {
210
- if (stopReason !== undefined)
211
- return true;
212
- if (args.signal?.aborted) {
213
- stopReason = "abort";
214
- return true;
215
- }
216
- if (Date.now() >= deadline) {
217
- stopReason = "budget";
218
- return true;
219
- }
220
- return false;
221
- };
222
- const pool = mapWithConcurrency(args.targets.map((target, index) => ({ target, index })), Math.max(1, args.concurrency), async ({ target, index }) => {
223
- if (shouldStop())
224
- return;
225
- const running = args.run(target, batchAbort.signal);
226
- // Attached HERE, at dispatch, so it is the promise's first
227
- // continuation — see `settledBeforeClose`. A value that only exists
228
- // because the batch was cut (a killed spawn's runner error, or
229
- // `safeSpawnAsync`'s synchronous "Spawn aborted before start") settles
230
- // strictly after the close and is therefore never recorded, so the
231
- // caller defers that target instead of reading it as a completed run.
232
- const stamp = running.then((value) => {
233
- if (closed)
234
- return;
235
- settledBeforeClose.add(index);
236
- results.push({ status: "fulfilled", value });
237
- }, (reason) => {
238
- if (closed)
239
- return;
240
- settledBeforeClose.add(index);
241
- results.push({ status: "rejected", reason });
242
- });
243
- // The stamp swallows both settlements, so this only keeps the pool's
244
- // concurrency slot held for as long as the work actually runs.
245
- await stamp;
246
- });
247
- // The pool's mapper swallows every throw from an async `run`; a `run` that throws
248
- // SYNCHRONOUSLY still rejects the pool and closes the whole batch (everything defers,
249
- // nothing publishes clean). Production runners are async, so this is fail-safe, not reachable.
250
- void pool.catch(() => { });
251
- await new Promise((resolve) => {
252
- let settled = false;
253
- const finish = (reason) => {
254
- if (settled)
255
- return;
256
- settled = true;
257
- if (reason !== undefined)
258
- stopReason ??= reason;
259
- // Latch BEFORE resolving, so no continuation scheduled by the kill
260
- // below can slip a result in behind the caller's back.
261
- closed = true;
262
- // Kill whatever is still in flight. Without this the pool ran on past
263
- // the bound: a stuck vitest spawn kept a core busy to its own 60s
264
- // timeout, long after the turn had moved on.
265
- batchAbort.abort();
266
- clearTimeout(timer);
267
- args.signal?.removeEventListener("abort", onAbort);
268
- resolve();
269
- };
270
- const onAbort = () => finish("abort");
271
- const timer = setTimeout(() => finish("budget"), budgetMs);
272
- // A pending batch timer must never hold the process open.
273
- timer.unref?.();
274
- if (args.signal?.aborted)
275
- finish("abort");
276
- else
277
- args.signal?.addEventListener("abort", onAbort, { once: true });
278
- void pool.then(() => finish(), () => finish());
279
- });
280
- return {
281
- results,
282
- deferred: args.targets.filter((_, index) => !settledBeforeClose.has(index)),
283
- stopReason,
284
- };
285
- }
286
- /**
287
- * #1617: turn_end reads gitleaks/govulncheck/trivy straight from their
288
- * session-scan caches and formats them into advisory/blocker text — a
289
- * reporting lane parallel to (and, before this fix, entirely bypassing)
290
- * `dispatcher.ts:924`'s `applyDispositions` filter. An agent-marked
291
- * false-positive/won't-fix on one of these findings never suppressed it
292
- * here, so it re-reported on every turn.
293
- *
294
- * Filters `findings` through the SAME anchor derivation the dispatch path
295
- * and `lens_diagnostics mode=full` use (`applyDispositionsMultiFile` in
296
- * `diagnostic-dispositions.ts`), keyed off each lane's own canonical
297
- * `ProjectDiagnostic` adapter (`toDiagnostic`) — the exact tool/rule/message
298
- * identity `lens_diagnostics` already surfaces and `lens_diagnostic_mark`
299
- * already anchors a mark against, not a second, cloned identity that would
300
- * silently diverge from what the agent actually marked.
301
- *
302
- * Returns the surviving findings plus how many were dropped, so a caller can
303
- * still surface a "suppressed by disposition: N" trace (the #1616
304
- * suppressed-bucket rule — a security finding must never vanish with no
305
- * trace, even when the disposition that dropped it is working as intended).
306
- */
307
- function filterFindingsByDisposition(findings, cwd, toDiagnostic) {
308
- if (findings.length === 0)
309
- return { kept: findings, suppressed: 0 };
310
- const candidates = findings.map((finding) => ({
311
- finding,
312
- diagnostic: toDiagnostic(finding),
313
- }));
314
- const survivors = new Set(applyDispositionsMultiFile(candidates.map((c) => c.diagnostic), cwd, (d) => d.filePath));
315
- const kept = candidates
316
- .filter((c) => survivors.has(c.diagnostic))
317
- .map((c) => c.finding);
318
- return { kept, suppressed: findings.length - kept.length };
319
- }
320
- /**
321
- * Would writing `next` over `prev` throw away a good scan for a failed one?
322
- *
323
- * A failed run carries no findings. Writing it evicts the last good result, and
324
- * every later reader then serves the failure as the answer — a 194-byte "not
325
- * available" record replaced 149 KB of real findings in every dogfood project
326
- * (#925, #1467). Callers keep the previous cache when this returns true.
327
- */
328
- function wouldPoisonCache(prev, next) {
329
- return !next.success && prev?.data.success === true;
330
- }
331
- // LSP idle reset scheduling — prevents thrashing by delaying shutdown
332
- let lspIdleResetTimeout = null;
333
- // #1618: set while this timer's fire is deferred behind an in-flight
334
- // workspace sweep (see `scheduleLSPIdleReset`'s `isWorkspaceSweepActive`
335
- // branch). `cancelLSPIdleReset` must be able to cancel THIS too — otherwise
336
- // an active-editing turn that cancels idle reset while a sweep is still
337
- // running would have it silently resurrected once the sweep finishes, even
338
- // though the session is no longer idle.
339
- let pendingSweepRearm = null;
340
- function emitIdleResetReporterWarning(reportErr) {
341
- try {
342
- process.emitWarning(`pi-lens LSP idle reset error reporter failed: ${reportErr}`, { code: "PI_LENS_LSP_IDLE_RESET_REPORTER_FAILED" });
343
- }
344
- catch {
345
- // Preserve the detached-timer invariant: this path must never crash.
346
- void reportErr;
347
- }
348
- }
349
- function reportIdleResetError(onError, err) {
350
- try {
351
- onError?.(err);
352
- }
353
- catch (reportErr) {
354
- emitIdleResetReporterWarning(reportErr);
355
- }
356
- }
357
- function scheduleLSPIdleReset(resetFn, delayMs, options = {}) {
358
- // Clear any pending reset to avoid multiple timers. #1618: also cancel a
359
- // rearm still waiting on a prior sweep's hold — otherwise re-scheduling
360
- // here (this call) leaves that OLD waiter armed too, and the sweep's
361
- // eventual release would fire a SECOND, independent `scheduleLSPIdleReset`
362
- // alongside this fresh one.
363
- if (lspIdleResetTimeout) {
364
- clearTimeout(lspIdleResetTimeout);
365
- }
366
- if (pendingSweepRearm) {
367
- pendingSweepRearm.cancelled = true;
368
- pendingSweepRearm = null;
369
- }
370
- lspIdleResetTimeout = setTimeout(() => {
371
- lspIdleResetTimeout = null;
372
- // #1618: a full workspace sweep (`lens_diagnostics mode=full`) grants
373
- // itself a wall-clock ceiling that can outlive this timer's delay — this
374
- // used to fire straight into an in-flight sweep and destroy the very
375
- // service the sweep was actively touching, mislabeling every file the
376
- // sweep had not yet reached as budget exhaustion. Defer instead of
377
- // firing: re-arm a FRESH `delayMs` timer once the sweep releases its
378
- // hold, rather than resuming a countdown that's already elapsed (which
379
- // would fire the instant the hold releases) or destroying mid-sweep.
380
- if (isWorkspaceSweepActive()) {
381
- const rearmToken = { cancelled: false };
382
- pendingSweepRearm = rearmToken;
383
- runWhenWorkspaceSweepIdle(() => {
384
- if (rearmToken.cancelled)
385
- return;
386
- if (pendingSweepRearm === rearmToken)
387
- pendingSweepRearm = null;
388
- scheduleLSPIdleReset(resetFn, delayMs, options);
389
- });
390
- return;
391
- }
392
- try {
393
- if (options.isCurrentSession && !options.isCurrentSession()) {
394
- return;
395
- }
396
- if (options.isPrimarySession && !options.isPrimarySession()) {
397
- return;
398
- }
399
- resetFn();
400
- }
401
- catch (err) {
402
- // Detached timers run outside a pi event boundary. They must never crash
403
- // the extension process (for example if a host UI object was invalidated
404
- // by session replacement before the timer fired).
405
- reportIdleResetError(options.onError, err);
406
- }
407
- }, delayMs);
408
- // unref so this timer does not prevent the process from exiting naturally
409
- // (critical for subagent / --mode json -p usage where the process should
410
- // exit after completing its work, not wait 240 seconds for this to fire)
411
- lspIdleResetTimeout.unref();
412
- }
413
- // #1618 acceptance criterion 6: FULL_SCAN_WALL_CLOCK_MS (the full-sweep wall
414
- // clock ceiling, `tools/lens-diagnostics.ts`) must stay under EVERY idle
415
- // reset delay this module can arm — derived, not asserted, so the constants
416
- // can't drift back into a relationship where a still-running sweep can
417
- // outlive the timer. The AC1 hold above already makes a mid-sweep fire
418
- // impossible regardless of this margin; this is defense in depth against a
419
- // future caller that touches the LSP service outside
420
- // `runWorkspaceDiagnostics`' hold. `SWEEP_IDLE_SAFETY_MARGIN_MS` is
421
- // single-sourced from `workspace-sweep-hold.ts`, which also uses it for its
422
- // own max-hold-age failsafe — one tunable, not two.
423
- const DEFAULT_LSP_IDLE_RESET_MS = 240_000;
424
- function sweepDerivedFloorMs() {
425
- return getFullScanWallClockMs() + SWEEP_IDLE_SAFETY_MARGIN_MS;
426
- }
427
- /** The normal (non-subagent, non-budget-pressured) idle-reset delay. */
428
- function getBaseLspIdleResetMs() {
429
- return Math.max(DEFAULT_LSP_IDLE_RESET_MS, sweepDerivedFloorMs());
430
- }
431
- /**
432
- * #1618 (R4): the subagent-light (#713) and cross-process-budget-pressured
433
- * (#449) paths used to arm a flat, much SHORTER delay (60s default) than the
434
- * sweep's own 300s ceiling — a 5:1 inversion covered only by the AC1 hold.
435
- * Deriving this path too means AC6 ("the sweep's ceiling stays under every
436
- * idle-reset delay") holds universally, not just for the common path, and an
437
- * env override to either constant can never invert it (`Math.max` floors at
438
- * the derived value no matter how small the override pushes the other side).
439
- *
440
- * Accepted cost (deliberate, not incidental — see R6 in the PR body): under
441
- * default settings this now ALSO arms the ~360s derived floor rather than a
442
- * true 60s teardown, trading some of #713's "release a short-lived
443
- * subagent's fleet fast" benefit for AC6 holding without exceptions.
444
- */
445
- function getShortenedLspIdleResetMs() {
446
- return Math.max(getLspBudgetIdleTimeoutMs(), sweepDerivedFloorMs());
447
- }
448
- /** The idle-reset delay `handleTurnEnd` actually arms on a file-less turn —
449
- * exported so tests assert against the REAL computed value instead of a
450
- * hand-derived literal that can silently drift from this function. */
451
- export function getEffectiveLspIdleResetMs() {
452
- return isSubagentSession() || shouldShortenLspIdleTimeout()
453
- ? getShortenedLspIdleResetMs()
454
- : getBaseLspIdleResetMs();
455
- }
456
- export function cancelLSPIdleReset() {
457
- if (lspIdleResetTimeout) {
458
- clearTimeout(lspIdleResetTimeout);
459
- lspIdleResetTimeout = null;
460
- }
461
- if (pendingSweepRearm) {
462
- pendingSweepRearm.cancelled = true;
463
- pendingSweepRearm = null;
464
- }
465
- }
466
- function capTurnEndMessage(content) {
467
- const maxLines = RUNTIME_CONFIG.turnEnd.maxLines;
468
- const maxChars = RUNTIME_CONFIG.turnEnd.maxChars;
469
- let out = content;
470
- const lines = out.split("\n");
471
- if (lines.length > maxLines) {
472
- out = `${lines.slice(0, maxLines).join("\n")}\n... (truncated)`;
473
- }
474
- if (out.length > maxChars) {
475
- out = `${out.slice(0, maxChars)}\n... (truncated)`;
476
- }
477
- return out;
478
- }
479
- export async function handleTurnEnd(deps) {
480
- const { ctxCwd, getFlag, dbg, runtime, cacheManager, knipClient, deadCodeClients, depChecker, testRunnerClient, sessionId, host = "pi", owner, resetLSPService, resetFormatService, } = deps;
481
- const turnIndexAtDispatch = runtime.turnIndex;
482
- const clearOwnedTurnState = () => {
483
- if (runtime.turnIndex !== turnIndexAtDispatch) {
484
- dbg(`turn_end: retaining newer turn state (dispatch=${turnIndexAtDispatch}, current=${runtime.turnIndex})`);
485
- return;
486
- }
487
- cacheManager.clearTurnState(cwd, currentOwner);
488
- };
489
- // #449 slice 1: piggyback the instance-registry heartbeat on this existing
490
- // per-turn touchpoint rather than adding a new timer/interval. Cheap (reads
491
- // process.memoryUsage().rss, one read-modify-write of instances.json) and
492
- // fire-and-forget — the kill-switch check + no-op behavior live inside
493
- // updateHeartbeat itself, so this call site doesn't need to know about it.
494
- //
495
- // #620: intentionally RSS-only here — CPU%/LSP-child sampling (which shells
496
- // out to `pidusage`, and a full CIM query on Windows for a spawn's process
497
- // tree) is left to the quiet-window "instance_registry_heartbeat" task
498
- // (clients/quiet-window.ts's `buildHeartbeatResourcePatch`), which fires on
499
- // the idle `agent_settled` window rather than every single turn end. Every
500
- // turn end is a much hotter path than an idle window, and the issue's own
501
- // guardrail is not to let the measurement itself become a new source of
502
- // per-turn overhead worth investigating.
503
- void updateHeartbeat().catch(() => {
504
- // best-effort observability — never fail turn_end over this
505
- });
506
- const cwd = ctxCwd ?? process.cwd();
507
- let turnState = cacheManager.readTurnState(cwd);
508
- // A live foreign writer owns this worklist. Do not clear or consume another
509
- // pi/MCP session's files; a dead/aged owner is safely evicted instead.
510
- // #2504: `sessionStartedAt` dates the persisted worklist against THIS
511
- // session. Without it an ownerless turn-state.json — the resting shape
512
- // before #2504 — read back as "owned" no matter how old it was.
513
- const currentOwner = {
514
- ...(owner ?? {
515
- kind: "pi",
516
- id: runtime.telemetrySessionId,
517
- pid: process.pid,
518
- lastSeen: new Date().toISOString(),
519
- }),
520
- sessionStartedAt: owner?.sessionStartedAt ?? runtime.sessionStartedAt,
521
- };
522
- const access = cacheManager.getTurnStateAccess(cwd, currentOwner);
523
- // Captured BEFORE the eviction below rewrites the file: the owner the gate
524
- // actually judged. This pair is what would have settled #2504 from the
525
- // debug log alone.
526
- const gateOwnerLabel = turnState.owner
527
- ? `${turnState.owner.kind}:${turnState.owner.id}`
528
- : turnState.sessionId
529
- ? `legacy:${turnState.sessionId}`
530
- : "none";
531
- const sameProcessPiSessionHandoff = access === "foreign-live" &&
532
- currentOwner.kind === "pi" &&
533
- turnState.owner?.kind === "pi" &&
534
- turnState.owner.pid === process.pid &&
535
- turnState.owner.id !== currentOwner.id;
536
- if (access === "foreign-live" && !sameProcessPiSessionHandoff) {
537
- dbg(`turn_end: foreign live owner retained (${turnState.owner?.kind ?? "legacy"}:${turnState.owner?.id ?? turnState.sessionId})`);
538
- return;
539
- }
540
- if (access === "available" &&
541
- (turnState.files || turnState.owner || turnState.sessionId)) {
542
- dbg("turn_end: evicting stale turn-state owner");
543
- clearOwnedTurnState();
544
- turnState = cacheManager.readTurnState(cwd);
545
- }
546
- const files = Object.keys(turnState.files);
547
- /**
548
- * #2275: widget-footer sibling of #1950's inline-blocker cap, for the
549
- * widget store's OWN dependency-drift demotion
550
- * (`markWidgetFileBlockersStale`, driven by the freshness sweep further
551
- * down this function) — a completely separate store from
552
- * `RuntimeCoordinator`'s inline-blocker map, so it needed its own
553
- * delivery count (`WidgetDiagnostic.staleDeliveryCount`) rather than
554
- * inheriting one.
555
- *
556
- * Review F1: the population is what the footer RENDERED since the last
557
- * turn end, drained here — not every file that merely holds a demoted
558
- * row. The footer draws one record per pass (`withBlocking[0]`, its top
559
- * five entries) and may not be drawn at all, so a per-turn walk of the
560
- * whole store charged deliveries the agent never received and retired a
561
- * delivery early. This is the widget-surface analogue of the inline
562
- * loop's own `pendingDependencyDriftDeliveries` deferral below: both
563
- * commit a delivery only once the surface has actually served it. Every
564
- * `deliveryCount` reported to the ledger is therefore a count of RENDERS.
565
- *
566
- * Fix-round 3 (#2275 review F1): this drain/charge MUST run before the
567
- * `files.length === 0` early return below — a read-only turn (no
568
- * modified files) still repaints the footer and can draw a demoted row,
569
- * so a cap that only charged deliveries below the early return silently
570
- * starved on quiet turns: the footer re-rendered the same demoted row
571
- * every turn while the delivery count never advanced. The drain is a
572
- * Set.take() plus one map lookup per drained file — cheap enough to run
573
- * unconditionally on every turn end.
574
- */
575
- let widgetDemotedFindingsRetired = 0;
576
- for (const wPath of drainRenderedDependencyDriftFilePaths()) {
577
- const deliveryCount = incrementWidgetDependencyDriftDelivery(wPath);
578
- if (deliveryCount >= DEPENDENCY_DRIFT_MAX_DELIVERIES) {
579
- const capRetired = retireWidgetDependencyDriftBlockers(wPath);
580
- if (capRetired) {
581
- widgetDemotedFindingsRetired += 1;
582
- incrementDegradationCount({
583
- kind: "demoted-finding-retired",
584
- subject: `widget-blocker:${toRunnerDisplayPath(cwd, wPath)}`,
585
- 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`,
586
- });
587
- }
588
- }
589
- }
590
- // R1 (#1443 follow-up): a read-only turn (no files touched) must not take
591
- // the fast idle-reset path while a carried cascade run — or one still
592
- // settling — is waiting for its delivery opportunity. Falling through to
593
- // the normal pipeline lets the settle/drain/merge logic below run exactly
594
- // as it does for an edit turn, so a carried finding reaches the agent
595
- // instead of dying unrendered. `hasCascadeRuns()` is a cheap peek (no
596
- // pending work almost every turn), so the common read-only turn still
597
- // takes the early return below.
598
- // A foreign live owner must not deliver another session's pending findings.
599
- // A no-file turn falls through when this process has pending runner work so
600
- // the ordinary freshness gate and delivery cache can run. Max-cycle cleanup
601
- // below intentionally remains a terminal reset; its pending work stays in
602
- // the bounded handoff store for the next eligible turn.
603
- if (files.length === 0 && !runtime.hasCascadeRuns()) {
604
- // A genuinely clean session must invalidate the persisted guard record.
605
- // Blocker records are retained only while the runtime still reports one.
606
- if (getFlag("lens-guard") && !runtime.gitGuardHasBlockers) {
607
- const guardRecord = cacheManager.readCache("turn-end-findings", cwd)?.data;
608
- if (guardRecord?.sessionId === runtime.telemetrySessionId &&
609
- guardRecord.testFailures !== true) {
610
- cacheManager.clearCache("turn-end-findings", cwd);
611
- }
612
- }
613
- // #713: subagent sessions use a shorter idle reset (nominally 60s) — a
614
- // short-lived task agent holding a warm fleet for 4 minutes after its
615
- // last turn is pure waste under fan-out. Classify ONCE here so every
616
- // tick in this call path shares the same answer. PI_LENS_SUBAGENT_FULL=1
617
- // restores the base delay via isSubagentSession() returning false.
618
- // #1618: both branches route through `getEffectiveLspIdleResetMs` so
619
- // AC6's derivation applies universally — see that function's doc for
620
- // why the "shorter" path is not always literally 60s anymore.
621
- const idleResetMs = getEffectiveLspIdleResetMs();
622
- dbg(`turn_end: no modified files, scheduling LSP idle reset (${idleResetMs / 1000}s)`);
623
- if (!getFlag("no-lsp")) {
624
- const sessionGeneration = runtime.sessionGeneration;
625
- scheduleLSPIdleReset(resetLSPService, idleResetMs, {
626
- isCurrentSession: () => runtime.isCurrentSession(sessionGeneration),
627
- // #2157 fix round 2: a secondary (subagent) evaluation's own timer
628
- // must not release the primary's shared fleet — see the option's
629
- // doc comment on `scheduleLSPIdleReset`.
630
- isPrimarySession: () => {
631
- const activePrimarySessionId = getActiveSessionId();
632
- return (activePrimarySessionId === undefined ||
633
- activePrimarySessionId === runtime.telemetrySessionId);
634
- },
635
- onError: (err) => dbg(`lsp idle reset failed: ${err}`),
636
- });
637
- }
638
- resetFormatService();
639
- if (pendingRunnerFindingsSize() === 0)
640
- return;
641
- }
642
- // Cancel any pending idle reset since we're actively working. #1618: also
643
- // checks `pendingSweepRearm` — a timer deferred behind an in-flight
644
- // workspace sweep already nulled `lspIdleResetTimeout` (the setTimeout
645
- // callback clears it before checking the hold), so this guard used to
646
- // read "nothing pending" and skip the cancel while a rearm was still
647
- // queued to fire the instant the sweep released its hold — resurrecting
648
- // idle reset on a session that had since gone back to active editing.
649
- if (files.length > 0 && (lspIdleResetTimeout || pendingSweepRearm)) {
650
- cancelLSPIdleReset();
651
- dbg("turn_end: cancelled pending LSP idle reset (active editing)");
652
- }
653
- dbg(`turn_end: ${files.length} file(s) modified, cycles: ${turnState.turnCycles}/${turnState.maxCycles}, access: ${access}, owner: ${gateOwnerLabel}`);
654
- if (cacheManager.isMaxCyclesExceeded(cwd)) {
655
- dbg("turn_end: max cycles exceeded, clearing state and forcing through");
656
- clearOwnedTurnState();
657
- runtime.fixedThisTurn.clear();
658
- resetFormatService();
659
- return;
660
- }
661
- const turnEndStart = Date.now();
662
- const blockerParts = [];
663
- /**
664
- * #1622 review M2: findings the freshness gate demoted. A third tier between
665
- * blockers and advisories — not a blocker, because the cached coordinate is
666
- * untrustworthy; not an advisory, because the advisory label reads "no action
667
- * required this turn" and these DO require a re-scan. Each part carries its
668
- * own imperative preamble rather than inheriting that label.
669
- */
670
- const staleSecretParts = [];
671
- const advisoryParts = [];
672
- const projectDiagnosticsDelta = [];
673
- const projectDiagnosticsSources = new Set();
674
- // #1641: past-EOF gate. Runs BEFORE the dependency-drift sweep below — a
675
- // cheap statSync per cited file is worth paying first so the pricier
676
- // import-parsing sweep can skip anything already taken out of the
677
- // authoritative channel this turn (see blocker-past-eof.ts's module doc
678
- // for the full composition rule with #1631's gate).
679
- const blockerPastEofStart = Date.now();
680
- const blockerPastEof = sweepInlineBlockerPastEof(runtime, cwd);
681
- logLatency({
682
- type: "phase",
683
- toolName: "turn_end",
684
- filePath: cwd,
685
- phase: "blocker_past_eof_sweep",
686
- durationMs: Date.now() - blockerPastEofStart,
687
- metadata: {
688
- total: blockerPastEof.total,
689
- checked: blockerPastEof.checked,
690
- demoted: blockerPastEof.demoted,
691
- // #1944 review F3: `healed` is gone. Retirement makes the falling edge
692
- // unreachable on this store, so the field could only ever log zero —
693
- // see `BlockerPastEofCounts`.
694
- },
695
- });
696
- // #1631: freshness gate. A cached blocker is a verdict about the file AND
697
- // everything it imports; before re-serving it, sweep for out-of-band drift of
698
- // the file or its forward imports and demote drifted entries to a
699
- // `[stale — re-run to confirm]` advisory instead of re-asserting them at full
700
- // authority (#1419 demote-not-drop).
701
- const blockerFreshnessStart = Date.now();
702
- // #1790: widen the sweep's population with widget-store rows a cache-served
703
- // replay populated without ever touching RuntimeCoordinator's inline-blocker
704
- // map — see blocker-freshness.ts's `WidgetSweepBlockerEntry` doc for why this
705
- // is injected here rather than imported by blocker-freshness.ts itself.
706
- const blockerFreshness = await sweepInlineBlockerFreshness(runtime, cwd, {
707
- // #2982: the hook's own signal, so the self axis's filesystem work is
708
- // bounded by the same abort everything else in this handler honours.
709
- ...(deps.signal === undefined ? {} : { signal: deps.signal }),
710
- additionalEntries: getWidgetBlockingFilesForSweep().map((row) => ({
711
- filePath: row.filePath,
712
- recordedAtMs: row.recordedAtMs,
713
- demote: () => markWidgetFileBlockersStale(row.filePath, "dependency-drift"),
714
- })),
715
- });
716
- logLatency({
717
- type: "phase",
718
- toolName: "turn_end",
719
- filePath: cwd,
720
- phase: "blocker_freshness_sweep",
721
- durationMs: Date.now() - blockerFreshnessStart,
722
- metadata: {
723
- total: blockerFreshness.total,
724
- kept: blockerFreshness.kept,
725
- revalidated: blockerFreshness.revalidated,
726
- alreadyStale: blockerFreshness.alreadyStale,
727
- truncatedImports: blockerFreshness.truncatedImports,
728
- selfHealed: blockerFreshness.selfHealed,
729
- selfUnverifiable: blockerFreshness.selfUnverifiable,
730
- hashBudgetExhausted: blockerFreshness.hashBudgetExhausted,
731
- },
732
- });
733
- // Re-surface inline blockers from this turn that the agent didn't fix.
734
- // These were shown inline during write/edit but the agent moved on without resolving them.
735
- const unresolvedBlockers = runtime.getInlineBlockersSnapshot();
736
- /** #1944/#1950: demotions retired after their delivery limit. */
737
- let demotedFindingsRetired = 0;
738
- /**
739
- * #1950 fix-round F1: dependency-drift delivery-count commits, deferred
740
- * until this turn's content is confirmed NOT suppressed by the
741
- * `turn-end-findings-last` signature dedupe further down. That dedupe
742
- * silences a turn whose rendered content is byte-identical to the last
743
- * one actually delivered — the agent never sees a suppressed turn, so
744
- * committing the counter for it would count a delivery that didn't
745
- * happen. Each entry here is invoked only from the "not suppressed"
746
- * branch below.
747
- */
748
- const pendingDependencyDriftDeliveries = [];
749
- for (const { filePath: bPath, summary, stale, staleReason, } of unresolvedBlockers) {
750
- const displayPath = toRunnerDisplayPath(cwd, bPath);
751
- if (stale) {
752
- // #1631: demoted — out of the authoritative blocker channel and into the
753
- // advisory channel with a stale marker, so the agent is told to re-run
754
- // rather than pressured by a verdict that may already be resolved.
755
- //
756
- // #1944: the CHANNEL change is not enough. Until this call the advisory
757
- // embedded the blocker body verbatim, so the agent read "🔴 STOP — 11
758
- // issue(s) must be fixed" with dead line numbers under a hedge line it
759
- // ignored. Degrade the body itself, and — when the file shrank past the
760
- // cited lines, so no re-run can ever confirm it — retire the record
761
- // after this ONE delivery instead of re-serving it for the rest of the
762
- // session.
763
- const deadLines = blockerPastEof.deadLinesByPath.get(bPath) ?? [];
764
- const degraded = degradeDemotedFindingBody(summary, { deadLines });
765
- const retired = runtime.retireDemotedPastEofBlocker(bPath, deadLines);
766
- let retirementNote;
767
- if (retired) {
768
- demotedFindingsRetired += 1;
769
- // Bounded by the ledger's own per-kind/subject tally, and the subject
770
- // keeps the discriminating identity (which store, which file).
771
- incrementDegradationCount({
772
- kind: "demoted-finding-retired",
773
- subject: `inline-blocker:${displayPath}`,
774
- reason: `file shrank past cited line(s) ${deadLines.join(", ")}; retired after one degraded delivery`,
775
- });
776
- retirementNote = formatRetirementNote(deadLines);
777
- }
778
- else if (staleReason === "dependency-drift") {
779
- // #1950: a dependency-drift demotion is recoverable (its coordinates
780
- // are still in bounds), so it does NOT retire after one delivery like
781
- // the past-EOF case above — but nothing capped how many times the
782
- // SAME demoted-but-unconfirmed record re-serves, and incident data
783
- // showed repeat deliveries carrying near-zero information after the
784
- // first. Cap it at DEPENDENCY_DRIFT_MAX_DELIVERIES instead.
785
- //
786
- // The count driving THIS render is a peek (fix-round F1): the actual
787
- // increment is deferred to `pendingDependencyDriftDeliveries` below,
788
- // committed only once this turn's content is known to reach the
789
- // agent, so a suppressed turn's tentative render never advances the
790
- // stored count.
791
- const tentativeCount = runtime.peekInlineBlockerStaleDeliveryCount(bPath) + 1;
792
- if (tentativeCount >= DEPENDENCY_DRIFT_MAX_DELIVERIES) {
793
- retirementNote = formatDeliveryCapNote(tentativeCount);
794
- }
795
- pendingDependencyDriftDeliveries.push(() => {
796
- const deliveryCount = runtime.incrementInlineBlockerStaleDelivery(bPath);
797
- if (deliveryCount >= DEPENDENCY_DRIFT_MAX_DELIVERIES) {
798
- const capRetired = runtime.retireDemotedDependencyDriftBlocker(bPath);
799
- if (capRetired) {
800
- demotedFindingsRetired += 1;
801
- incrementDegradationCount({
802
- kind: "demoted-finding-retired",
803
- subject: `inline-blocker:${displayPath}`,
804
- reason: `capped after ${deliveryCount} deliveries with no re-run; re-run can still confirm`,
805
- });
806
- }
807
- }
808
- });
809
- }
810
- // @delivery-surface: runtime-turn:unresolved-inline-blocker
811
- advisoryParts.push(`${STALE_LINE_MARKER} ${displayPath}:\n${degraded.body}` +
812
- (retirementNote ? `\n${retirementNote}` : ""));
813
- }
814
- else {
815
- // @delivery-surface: runtime-turn:unresolved-inline-blocker
816
- blockerParts.push(`Unresolved from this turn — ${displayPath}:\n${summary}`);
817
- }
818
- }
819
- // Drain the deferred cascade computes kicked off this turn (#450). They ran
820
- // concurrently off the write hot path; wait a bounded time for them here so
821
- // their runs are available to the merge below. A compute still in flight at
822
- // the cap is carried over to the next turn_end (never dropped).
823
- const cascadeSettleStart = Date.now();
824
- const { settled, timedOut } = await runtime.settleCascadeRuns(cascadeSettleWaitMs(), { trackTurnEndClock: true });
825
- logLatency({
826
- type: "phase",
827
- toolName: "turn_end",
828
- filePath: cwd,
829
- phase: "cascade_settle_wait",
830
- durationMs: Date.now() - cascadeSettleStart,
831
- metadata: { settled, timedOut },
832
- });
833
- // Merge accumulated cascade results from all pipeline runs this turn.
834
- // Two-pass dedup:
835
- // 1. Primary-level: dedup by primary file (last writer wins).
836
- // 2. Neighbor-level: each neighbor is claimed by the latest cascade result
837
- // that covers it — suppresses stale neighbor state from earlier writes.
838
- const t0 = Date.now();
839
- const cascadeRuns = runtime.consumeCascadeRuns().filter((run) => {
840
- const originSeq = run.origin?.projectSeq;
841
- const originTurn = run.origin?.turnSeq;
842
- // A deferred result from AFTER a later write is not current state. Old test
843
- // fixtures without provenance remain accepted for compatibility.
844
- //
845
- // #1443: `turnSeq` alone is NOT a supersede signal, and it used to be an
846
- // unconditional reject. Every LATE run — one whose compute missed the
847
- // settle cap and was re-parked by `settleCascadeRuns`, and one the
848
- // quiet-window reconcile appended after this turn's predecessor already
849
- // consumed (carried across turn_start by `beginTurn`) — is BY DEFINITION
850
- // from an earlier turn, so `originTurn === runtime.turnIndex` was always
851
- // false for exactly the runs the carry-over was built to preserve. Both
852
- // producers' contracts were dead code: the measured cases were the two
853
- // highest-fan-out cascades of the day (38 and 40 neighbours).
854
- //
855
- // R2 (#1443 follow-up): `projectSeq` alone is NOT a per-file supersede
856
- // signal — it is GLOBAL, advancing on every pi-observed write anywhere in
857
- // the project. Rejecting on any mismatch meant an edit to an unrelated
858
- // file superseded a run that had nothing to do with it, reintroducing the
859
- // exact 38/40-neighbour loss #1443 was written to fix, one filter down.
860
- // `getFilesChangedSince` (#451) is the honest per-file signal: a run is
861
- // superseded only if its own primary file or one of its neighbours was
862
- // actually rewritten since it launched. A late-but-not-superseded run is
863
- // surfaced; a superseded one is dropped with a RECORD (never silently),
864
- // so the loss stays countable.
865
- if (originSeq !== undefined) {
866
- const changedSince = runtime.getFilesChangedSince(originSeq);
867
- if (changedSince.length > 0) {
868
- const changedSet = new Set(changedSince);
869
- const primaryKey = normalizeMapKey(path.resolve(run.filePath));
870
- const neighborKeys = [
871
- ...(run.result?.neighbors ?? []).map((n) => n.filePath),
872
- ...(run.selectedNeighborPaths ?? []),
873
- ].map((filePath) => normalizeMapKey(path.resolve(filePath)));
874
- const supersededByOwnFile = changedSet.has(primaryKey) ||
875
- neighborKeys.some((k) => changedSet.has(k));
876
- if (supersededByOwnFile) {
877
- logCascade({
878
- phase: "cascade_carry_over_drop",
879
- filePath: run.filePath,
880
- neighborCount: run.neighborCount,
881
- diagnosticCount: run.diagnosticCount,
882
- reason: "superseded_by_later_write",
883
- metadata: {
884
- originProjectSeq: originSeq,
885
- projectSeq: runtime.projectSeq,
886
- originTurnSeq: originTurn,
887
- turnIndex: runtime.turnIndex,
888
- carriedTurns: run.carriedTurns,
889
- changedFiles: changedSince,
890
- },
891
- });
892
- return false;
893
- }
894
- }
895
- }
896
- return true;
897
- });
898
- const cascadeResults = cascadeRuns.flatMap((r) => r.result ? [r.result] : []);
899
- // Fix B (#3167): which results belong to a CARRIED run — the label is
900
- // per-result at render time, so the run→result pairing must survive the
901
- // flatMap above (which discards the wrapper).
902
- // Fix B (#3168 F6): the success-path record — latency.log could not show
903
- // the feature ever rendered without it (the only phases were the drop/
904
- // settle records). One bounded record per turn, emitted after both render
905
- // sections below.
906
- let carriedRunsRendered = 0;
907
- let labeledAdvisories = 0;
908
- const carriedMetaByResult = new Map();
909
- for (const r of cascadeRuns) {
910
- if (r.result && (r.carriedTurns ?? 0) > 0 && r.carriedTurns !== undefined) {
911
- carriedMetaByResult.set(r.result, {
912
- carriedTurns: r.carriedTurns,
913
- observedAt: r.observedAt,
914
- });
915
- }
916
- }
917
- // #1550 class sweep: every cascade record below summarises `cascadeResults`
918
- // — runs, which carry their own paths and can be carried across turns
919
- // (#1443) — so labelling them with the turn's first EDITED file is the same
920
- // mis-attribution the `cascade_indeterminate` fix removes. On a read-only
921
- // drain turn `files` is empty and the old `?? cwd` fallback stamped a bare
922
- // DIRECTORY as the record's file. These three are turn-level AGGREGATES (no
923
- // per-file cause is claimed), so one label suffices; the edited file and cwd
924
- // stay as fallbacks.
925
- const cascadeLogFilePath = cascadeResults[0]?.filePath ?? files[0] ?? cwd;
926
- if (cascadeResults.length > 0) {
927
- const seen = new Map();
928
- for (const result of cascadeResults) {
929
- seen.set(normalizeMapKey(result.filePath), result);
930
- }
931
- // Iterate in reverse so the latest result claims each neighbor first.
932
- const neighborOwner = new Map();
933
- for (const result of [...seen.values()].reverse()) {
934
- const pk = normalizeMapKey(result.filePath);
935
- for (const n of result.neighbors) {
936
- const nk = normalizeMapKey(n.filePath);
937
- if (!neighborOwner.has(nk))
938
- neighborOwner.set(nk, pk);
939
- }
940
- }
941
- const parts = [];
942
- // #1446 item 1: track what actually gets injected — a suppressed result
943
- // (real formatted cascade text, but every one of its neighbors was claimed
944
- // by a LATER result — see the reverse-iteration ownership pass above) was
945
- // previously indistinguishable from "no output"; this counts it explicitly
946
- // instead of letting it vanish.
947
- let injectedNeighborCount = 0;
948
- let injectedDiagnosticCount = 0;
949
- let suppressedByOwnership = 0;
950
- for (const result of seen.values()) {
951
- const pk = normalizeMapKey(result.filePath);
952
- const ownsAny = result.neighbors.some((n) => neighborOwner.get(normalizeMapKey(n.filePath)) === pk);
953
- if (ownsAny && result.formatted) {
954
- // Fix B (#3167/#3168): a carried run's re-rendered blocker is labeled
955
- // so the agent can tell it from a fresh observation — with the
956
- // run's own observation age (#3168 F3).
957
- const carryMeta = carriedMetaByResult.get(result);
958
- const carrySuffix = cascadeCarrySuffix(carryMeta?.carriedTurns, carryMeta?.observedAt);
959
- parts.push(carrySuffix
960
- ? `${result.formatted}\n${carrySuffix}`
961
- : result.formatted);
962
- if (carrySuffix)
963
- carriedRunsRendered += 1;
964
- injectedNeighborCount += result.neighbors.length;
965
- injectedDiagnosticCount += result.neighbors.reduce((s, n) => s + n.diagnostics.length, 0);
966
- }
967
- else if (!ownsAny && result.formatted) {
968
- suppressedByOwnership++;
969
- }
970
- }
971
- // Suggest tests for cascade neighbors (files with diagnostics)
972
- const neighborFilesWithErrors = cascadeResults
973
- .flatMap((r) => r.neighbors)
974
- .filter((n) => n.diagnostics.length > 0)
975
- .map((n) => n.filePath);
976
- const uniqueNeighborFiles = [...new Set(neighborFilesWithErrors)];
977
- let testSuggestionCount = 0;
978
- if (uniqueNeighborFiles.length > 0 &&
979
- typeof testRunnerClient.suggestTestFiles === "function") {
980
- const testSuggestions = testRunnerClient.suggestTestFiles(uniqueNeighborFiles, cwd);
981
- testSuggestionCount = testSuggestions.length;
982
- // #1446 item 2: this path previously emitted nothing to any log — a
983
- // zero-suggestion outcome (neighbors had errors but no test file
984
- // resolved for any of them) is the more interesting case, so it is
985
- // recorded on the same phase rather than only logging on a hit.
986
- logCascade({
987
- phase: "cascade_test_targets",
988
- filePath: cascadeLogFilePath,
989
- neighborCount: uniqueNeighborFiles.length,
990
- metadata: {
991
- neighborFiles: uniqueNeighborFiles.slice(0, 10),
992
- suggestedTestFiles: testSuggestions
993
- .slice(0, 10)
994
- .map((s) => s.testFile),
995
- runner: testSuggestions[0]?.runner,
996
- truncated: testSuggestions.length > 10,
997
- zeroSuggestions: testSuggestions.length === 0,
998
- },
999
- });
1000
- if (testSuggestions.length > 0) {
1001
- const testLines = testSuggestions
1002
- .slice(0, 5)
1003
- .map((s) => ` ${toRunnerDisplayPath(cwd, s.testFile)} (${s.runner})`);
1004
- let testSection = `🧪 Likely tests for affected neighbors:\n${testLines.join("\n")}`;
1005
- if (testSuggestions.length > 5) {
1006
- testSection += `\n ... and ${testSuggestions.length - 5} more`;
1007
- }
1008
- parts.push(testSection);
1009
- }
1010
- }
1011
- if (parts.length > 0) {
1012
- const section = parts.join("\n\n");
1013
- // @delivery-surface: runtime-turn:cascade-blocker
1014
- blockerParts.push(section);
1015
- // #1446 item 1: proves the cascade section reached `blockerParts` —
1016
- // i.e. it was QUEUED for persistence into the turn-end advisory — not
1017
- // that it reached the agent. The counters alone (cascade_result,
1018
- // cascade_turn_end) never confirmed even that much, only computation.
1019
- // Actual delivery happens later, via consumeTurnEndFindings/
1020
- // peekTurnEndFindings, and can still be suppressed after this point
1021
- // (e.g. allFilesDeleted, cross-turn dedup, or the session ending
1022
- // before the next turn_end drains it) — this record does not prove
1023
- // the agent ever saw the text.
1024
- logCascade({
1025
- phase: "cascade_injected",
1026
- filePath: cascadeLogFilePath,
1027
- neighborCount: injectedNeighborCount,
1028
- diagnosticCount: injectedDiagnosticCount,
1029
- metadata: {
1030
- sectionChars: section.length,
1031
- testSuggestionCount,
1032
- suppressedByOwnership,
1033
- },
1034
- });
1035
- }
1036
- logCascade({
1037
- phase: "cascade_turn_end",
1038
- filePath: cascadeLogFilePath,
1039
- neighborCount: cascadeResults.reduce((s, r) => s + r.neighbors.length, 0),
1040
- diagnosticCount: cascadeResults.reduce((s, r) => s + r.neighbors.reduce((ns, n) => ns + n.diagnostics.length, 0), 0),
1041
- metadata: {
1042
- fileCount: cascadeResults.length,
1043
- mergedResults: seen.size,
1044
- },
1045
- });
1046
- }
1047
- // #1023: surface an HONEST note whenever a cascade run could not compute
1048
- // downstream impact (degraded/over-cap graph, missing node, a thrown compute,
1049
- // or a deliberately budget-truncated neighbor set) — never a silent all-clear
1050
- // (#533). This goes to the ADVISORY tier,
1051
- // NOT the blocker tier: in an over-cap monorepo the graph is `skipped` on
1052
- // every edit, so a blocker would fire hard and never clear turn state every
1053
- // turn (over-escalation — the mirror of the silent-all-clear bug). Advisory
1054
- // still reaches the agent, just without the blocker mechanics. Keyed strictly
1055
- // off the `indeterminate` marker threaded by the compute; a healthy build
1056
- // with a genuinely empty dependent set carries no marker and stays silent
1057
- // (over-correction guard).
1058
- const indeterminateRuns = cascadeRuns.filter((r) => r.indeterminate);
1059
- if (indeterminateRuns.length > 0) {
1060
- // #1104 (review P3 on PR #1143, rides with the resultId main body): this
1061
- // preamble used to hardcode a graph-unavailability frame for EVERY
1062
- // indeterminate reason. That's accurate for `graph_degraded`/
1063
- // `missing_node`/`error` (the graph really couldn't produce a dependent
1064
- // set), but `lsp_binding_rejected` is a DIFFERENT failure shape — the
1065
- // graph WAS available and dependents WERE derived; only their LSP
1066
- // diagnostics display was withheld because a fallback snapshot's content
1067
- // binding didn't match current disk. Saying "the review graph was
1068
- // unavailable" for that case mis-attributes the cause. Bucket by reason
1069
- // family so each gets its own accurate frame.
1070
- const buildAdvisory = (runs, frame) => {
1071
- if (runs.length === 0)
1072
- return undefined;
1073
- const byDetail = new Map();
1074
- for (const r of runs) {
1075
- const detail = r.indeterminate?.detail ?? frame.fallbackDetail(r);
1076
- const files = byDetail.get(detail) ?? [];
1077
- files.push(toRunnerDisplayPath(cwd, r.filePath));
1078
- byDetail.set(detail, files);
1079
- }
1080
- const lines = [];
1081
- for (const [detail, filesRaw] of byDetail) {
1082
- const files = [...new Set(filesRaw)];
1083
- const shown = files.slice(0, 5).join(", ");
1084
- const more = files.length > 5 ? ` (+${files.length - 5} more)` : "";
1085
- lines.push(` • ${detail}: ${shown}${more}`);
1086
- }
1087
- const fileCount = new Set(runs.map((r) => normalizeMapKey(r.filePath)))
1088
- .size;
1089
- const reasons = [...byDetail.keys()].join("; ");
1090
- return `${frame.lead(fileCount, reasons)}\n${lines.join("\n")}`;
1091
- };
1092
- // #1445: `excluded_by_role` (test files excluded from the graph BY DESIGN,
1093
- // #260) is never agent-facing — it is not a graph failure, and #1080
1094
- // already excludes test-role files from every neighbor surface, so "a
1095
- // clean result does not cover them" would itself be a false claim. It
1096
- // stays visible in the `cascade_indeterminate` log below (metadata-only,
1097
- // info-level) so the log can tell an intentional exclusion from a real
1098
- // graph gap, but it never reaches `buildAdvisory`/the agent.
1099
- const graphRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason !== "lsp_binding_rejected" &&
1100
- r.indeterminate?.reason !== "excluded_by_role" &&
1101
- r.indeterminate?.reason !== "budget_truncated" &&
1102
- r.indeterminate?.budget === undefined);
1103
- const bindingRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason === "lsp_binding_rejected" &&
1104
- r.indeterminate?.budget === undefined);
1105
- // Budget coverage can be merged into a graph or binding marker, so its
1106
- // advisory bucket follows the evidence rather than replacing that reason.
1107
- const budgetRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason === "budget_truncated" ||
1108
- r.indeterminate?.budget !== undefined);
1109
- // Factual/informational phrasing — the advisory tier wraps this with an
1110
- // "ℹ️ Advisory — no action required this turn:" label, so an imperative
1111
- // ("review dependents manually") would contradict it. The #533 substance
1112
- // stays: a clean cascade result does NOT cover these files' dependents.
1113
- // Fix B (#3167/#3168 F4): a coverage advisory computed from a CARRIED
1114
- // indeterminate run describes the previous turn's evidence — label it so
1115
- // the absence-of-coverage statement is not read as current. A MIXED
1116
- // bucket (some carried, some fresh) is left UNLABELED: a Math.max
1117
- // suffix on the finished multi-line advisory would attach the carry to
1118
- // a file that was not carried.
1119
- const withCarryLabel = (advisory, runs) => {
1120
- if (advisory === undefined)
1121
- return undefined;
1122
- const carried = runs.filter((r) => (r.carriedTurns ?? 0) > 0);
1123
- if (carried.length === 0 || carried.length !== runs.length) {
1124
- return advisory;
1125
- }
1126
- // #3168 F13: the bucket's age is the OLDEST carried observation, and
1127
- // only when EVERY carried run carries one. A `Math.min` sentinel
1128
- // (`?? Number.MAX_SAFE_INTEGER`) silently ignored the unstamped runs
1129
- // and stated a confident age for a bucket that contains an unaged
1130
- // one; one missing stamp collapses the age half to the helper's
1131
- // neutral "scan age unknown" wording instead.
1132
- const stamps = carried.map((r) => r.observedAt);
1133
- const observedAt = stamps.every((s) => s !== undefined)
1134
- ? Math.min(...stamps)
1135
- : undefined;
1136
- const suffix = cascadeCarrySuffix(carried[0]?.carriedTurns, observedAt);
1137
- if (!suffix)
1138
- return advisory;
1139
- labeledAdvisories += 1;
1140
- // #3168 F11: newline, as the blocker path does above. A space join
1141
- // welded the suffix onto the LAST bullet of a multi-bullet advisory,
1142
- // so the carry label read as a property of that one file.
1143
- return `${advisory}\n${suffix}`;
1144
- };
1145
- const graphAdvisory = withCarryLabel(buildAdvisory(graphRuns, {
1146
- lead: (fileCount, reasons) => `Cascade could not compute downstream impact for ${fileCount} edited file(s) this turn — ` +
1147
- `the review graph was unavailable (${reasons}), so their dependents were not ` +
1148
- `cascade-checked and a clean cascade result does not cover them.`,
1149
- fallbackDetail: (r) => r.indeterminate?.reason === "missing_node"
1150
- ? "changed file not in the review graph"
1151
- : "review graph unavailable",
1152
- }), graphRuns);
1153
- // @delivery-surface: runtime-turn:cascade-coverage-advisory
1154
- if (graphAdvisory)
1155
- advisoryParts.push(graphAdvisory);
1156
- const bindingAdvisory = withCarryLabel(buildAdvisory(bindingRuns, {
1157
- lead: (fileCount, reasons) => `Cascade identified dependents for ${fileCount} edited file(s) this turn, but their ` +
1158
- `diagnostics could not be freshly confirmed (${reasons}) and were withheld — a clean ` +
1159
- `cascade result does not cover them.`,
1160
- fallbackDetail: () => "cascade diagnostics withheld (binding rejected)",
1161
- }), bindingRuns);
1162
- // @delivery-surface: runtime-turn:cascade-coverage-advisory
1163
- if (bindingAdvisory)
1164
- advisoryParts.push(bindingAdvisory);
1165
- const budgetAdvisory = withCarryLabel(buildAdvisory(budgetRuns, {
1166
- lead: (fileCount, reasons) => `Cascade checked the selected neighbors for ${fileCount} edited file(s) this turn, ` +
1167
- `but some eligible dependents were not checked because the cascade budget ` +
1168
- `was exhausted (${reasons}); a clean cascade result does not cover them.`,
1169
- fallbackDetail: (r) => {
1170
- const budget = r.indeterminate?.budget;
1171
- if (!budget)
1172
- return "cascade budget omitted eligible dependents";
1173
- const detail = `cascade budget checked ${budget.selectedCount} of ${budget.eligibleCount} eligible dependents (${budget.truncatedCount} omitted)`;
1174
- return budget.transitiveTruncated
1175
- ? `${detail}; transitive expansion was capped before all eligible dependents were enumerated`
1176
- : detail;
1177
- },
1178
- }), budgetRuns);
1179
- // @delivery-surface: runtime-turn:cascade-coverage-advisory
1180
- if (budgetAdvisory)
1181
- advisoryParts.push(budgetAdvisory);
1182
- const fileCount = new Set(indeterminateRuns.map((r) => normalizeMapKey(r.filePath))).size;
1183
- // #1550: attribute each reason to the file that PRODUCED it. This record
1184
- // used to stamp `filePath: files[0] ?? cwd` — the turn's first EDITED file
1185
- // — and a bare `reasons` array with no file association. The two sets are
1186
- // disjoint: a run can be carried across turns (#1443), and an edited file
1187
- // can skip the graph entirely (markdown/JSON return `non_code` before
1188
- // computeImpactCascade ever runs). So the log blamed a file that could not
1189
- // have produced the reason — a markdown file credited with `missing_node`,
1190
- // a non-test source file credited with `excluded_by_role` — and the defect
1191
- // read as "concentrated on test files" only because the first edited file
1192
- // of a turn usually is one. `fileCount` and the agent-facing advisory
1193
- // already keyed off `r.filePath`; only this record's labels did not.
1194
- const byFile = indeterminateRuns.map((r) => ({
1195
- file: toRunnerDisplayPath(cwd, r.filePath),
1196
- reason: r.indeterminate?.reason,
1197
- ...(r.indeterminate?.detail && { detail: r.indeterminate.detail }),
1198
- ...(r.indeterminate?.budget && { budget: r.indeterminate.budget }),
1199
- ...(r.indeterminate?.diagnostic && {
1200
- diagnostic: r.indeterminate.diagnostic,
1201
- }),
1202
- }));
1203
- logCascade({
1204
- phase: "cascade_indeterminate",
1205
- // The first indeterminate run's own file. `files[0] ?? cwd` survives only
1206
- // as a last resort for a run with no path at all.
1207
- filePath: indeterminateRuns[0]?.filePath ?? files[0] ?? cwd,
1208
- metadata: {
1209
- fileCount,
1210
- reasons: indeterminateRuns.map((r) => r.indeterminate?.reason),
1211
- byFile: byFile.slice(0, 20),
1212
- ...(byFile.length > 20 && { byFileTruncated: byFile.length - 20 }),
1213
- },
1214
- });
1215
- }
1216
- // Fix B (#3168 F6): the success-path record — see the counters above.
1217
- if (carriedRunsRendered > 0 || labeledAdvisories > 0) {
1218
- logCascade({
1219
- phase: "cascade_carry_rendered",
1220
- filePath: cascadeLogFilePath,
1221
- metadata: { carriedRunsRendered, labeledAdvisories },
1222
- });
1223
- }
1224
- const cascadeSkipped = {
1225
- blockers: 0,
1226
- non_code: 0,
1227
- no_neighbors: 0,
1228
- clean: 0,
1229
- indeterminate: 0,
1230
- error: 0,
1231
- };
1232
- for (const r of cascadeRuns) {
1233
- if (r.skipReason)
1234
- cascadeSkipped[r.skipReason] = (cascadeSkipped[r.skipReason] ?? 0) + 1;
1235
- }
1236
- logLatency({
1237
- type: "phase",
1238
- toolName: "turn_end",
1239
- filePath: cwd,
1240
- phase: "cascade_merge",
1241
- durationMs: Date.now() - t0,
1242
- metadata: {
1243
- runsTotal: cascadeRuns.length,
1244
- resultCount: cascadeResults.length,
1245
- neighborCount: cascadeRuns.reduce((s, r) => s + r.neighborCount, 0),
1246
- diagnosticCount: cascadeRuns.reduce((s, r) => s + r.diagnosticCount, 0),
1247
- skipped: cascadeSkipped,
1248
- },
1249
- });
1250
- const t2 = Date.now();
1251
- let knipMeta = {};
1252
- if (runtime.isStartupScanInFlight("knip")) {
1253
- dbg("turn_end: skipping knip (startup scan still in flight)");
1254
- knipMeta = { skipped: true };
1255
- }
1256
- else {
1257
- // Let KnipClient resolve/validate a real JS project root before probing or
1258
- // auto-installing knip. Non-JS repos (for example Unity projects) should not
1259
- // run tool checks every turn. Also back off after a timeout/kill so every
1260
- // agent turn does not spend 30s launching another heavyweight knip process.
1261
- const prevKnip = cacheManager.readCache("knip", cwd);
1262
- // An availability failure is NOT a hard knip failure: knip never ran, so
1263
- // there is nothing to back off from, and backing off would make an
1264
- // expiring probe verdict permanent again (#1467).
1265
- const previousFailedHard = prevKnip &&
1266
- !prevKnip.data.success &&
1267
- !prevKnip.data.failureKind &&
1268
- /(timed out|killed|SIGTERM|SIGKILL|SIGABRT)/i.test(prevKnip.data.summary);
1269
- if (previousFailedHard) {
1270
- dbg(`turn_end: skipping knip after recent failure: ${prevKnip.data.summary}`);
1271
- knipMeta = { skipped: true, reason: prevKnip.data.summary };
1272
- }
1273
- else {
1274
- const knipResult = await knipClient.analyze(cwd, getKnipIgnorePatterns(), {
1275
- projectSeq: runtime.projectSeq,
1276
- });
1277
- // Never overwrite a good scan with a failure (#925, #1467): the last
1278
- // good result stays until a new successful scan replaces it.
1279
- const knipWouldPoison = wouldPoisonCache(prevKnip, knipResult);
1280
- if (knipWouldPoison) {
1281
- dbg(`turn_end: keeping last good knip cache; this run failed: ${knipResult.summary}`);
1282
- }
1283
- else {
1284
- cacheManager.writeCache("knip", knipResult, cwd);
1285
- }
1286
- knipMeta = {
1287
- execution: knipResult.execution ?? "executed",
1288
- success: knipResult.success,
1289
- totalIssues: knipResult.issues.length,
1290
- newIssues: 0,
1291
- blockerIssues: 0,
1292
- ...(!knipResult.success && { reason: knipResult.summary }),
1293
- ...(knipResult.failureKind && { failureKind: knipResult.failureKind }),
1294
- ...(knipWouldPoison && { cacheKept: true }),
1295
- };
1296
- if (knipResult.success && knipResult.issues.length > 0) {
1297
- // Deliberately excludes the line number — see stableFindingKey's
1298
- // doc comment (#1483: mirrors the dead-code fix in #1477).
1299
- const issueKey = (i) => stableFindingKey(i.type, i.file, i.name, i.package);
1300
- const prevKeys = new Set((prevKnip?.data?.issues ?? []).map(issueKey));
1301
- const modifiedSet = new Set(files.map((f) => resolveRunnerPath(cwd, f)));
1302
- const newIssues = knipResult.issues.filter((issue) => {
1303
- if (prevKeys.has(issueKey(issue)))
1304
- return false;
1305
- if (!issue.file)
1306
- return false;
1307
- const abs = resolveRunnerPath(cwd, issue.file);
1308
- return modifiedSet.has(abs);
1309
- });
1310
- knipMeta.newIssues = newIssues.length;
1311
- if (newIssues.length > 0) {
1312
- projectDiagnosticsDelta.push(...knipIssuesToProjectDiagnostics(cwd, newIssues));
1313
- projectDiagnosticsSources.add("knip");
1314
- }
1315
- const blockerIssues = newIssues.filter((i) => i.type === "unlisted" || i.type === "bin");
1316
- knipMeta.blockerIssues = blockerIssues.length;
1317
- if (blockerIssues.length > 0) {
1318
- let report = "🔴 New unresolved imports/deps in modified code (Knip):\n";
1319
- let firstPath = null;
1320
- for (const issue of blockerIssues.slice(0, 5)) {
1321
- const display = issue.file
1322
- ? toRunnerDisplayPath(cwd, issue.file)
1323
- : "(unknown)";
1324
- if (!firstPath && display !== "(unknown)")
1325
- firstPath = display;
1326
- report += ` ${display}${issue.line ? `:${issue.line}` : ""} — ${issue.type}: ${issue.name}\n`;
1327
- }
1328
- if (firstPath) {
1329
- report += ` First location: ${firstPath}\n`;
1330
- }
1331
- // @delivery-surface: runtime-turn:knip-blocker
1332
- blockerParts.push(report);
1333
- }
1334
- // Turn-end injects only this turn's HIGH-CONFIDENCE, ATTRIBUTABLE
1335
- // delta: symbols in files the agent just edited that became unused
1336
- // (weren't flagged in the previous scan) — low-volume and actionable
1337
- // now. The FULL project-wide dead-code picture is deliberately NOT
1338
- // injected per turn (hundreds of mostly-pre-existing findings would
1339
- // drown the blockers and burn context every turn); it's available
1340
- // on demand via lens_diagnostics. The delta also feeds the session-slop
1341
- // record (`projectDiagnosticsDelta`) above.
1342
- const unusedExportDelta = newIssues.filter((i) => i.type === "export" || i.type === "enumMember");
1343
- if (unusedExportDelta.length > 0) {
1344
- let report = "⚠️ Newly unused exports in files you edited — check if callers need updating (Knip):\n";
1345
- for (const issue of unusedExportDelta.slice(0, 5)) {
1346
- const display = issue.file
1347
- ? toRunnerDisplayPath(cwd, issue.file)
1348
- : "(unknown)";
1349
- report += ` ${display}${issue.line ? `:${issue.line}` : ""} — ${issue.name}\n`;
1350
- }
1351
- // @delivery-surface: runtime-turn:knip-advisory
1352
- advisoryParts.push(report);
1353
- }
1354
- }
1355
- }
1356
- }
1357
- logLatency({
1358
- type: "phase",
1359
- toolName: "turn_end",
1360
- filePath: cwd,
1361
- phase: "knip",
1362
- durationMs: Date.now() - t2,
1363
- metadata: knipMeta,
1364
- });
1365
- // Cross-file dead-code (#127) for non-JS/TS languages, on knip's contract:
1366
- // re-scan only when this turn touched a file the client owns, then inject the
1367
- // ATTRIBUTABLE delta — symbols in those files that became unused because of
1368
- // the edit. The project-wide list is deliberately NOT injected per turn (the
1369
- // same reasoning as knip above) and stays available via lens_diagnostics.
1370
- // MUST run before the projectDiagnosticsDelta write below, or a dead-code-only
1371
- // turn would persist nothing.
1372
- const tDeadCode = Date.now();
1373
- const deadCodeMeta = {};
1374
- if (runtime.isStartupScanInFlight("dead-code")) {
1375
- dbg("turn_end: skipping dead-code (startup scan still in flight)");
1376
- deadCodeMeta.skipped = true;
1377
- deadCodeMeta.reason = "startup_scan_in_flight";
1378
- }
1379
- else if (deadCodeClients.length === 0) {
1380
- deadCodeMeta.reason = "no_clients";
1381
- }
1382
- else {
1383
- // The modified-file set costs a resolveRunnerPath per file, and that walks
1384
- // every ancestor to the filesystem root on a miss. Build it lazily, only
1385
- // once a client has actually claimed this project, so an all-JS repo with
1386
- // no dead-code client pays nothing per turn. Knip does the same.
1387
- let modifiedSet = null;
1388
- const modifiedFiles = () => (modifiedSet ??= new Set(files.map((f) => resolveRunnerPath(cwd, f))));
1389
- let newIssueTotal = 0;
1390
- const reasons = [];
1391
- // A malformed client or deps object must never abort turn_end. Before the
1392
- // per-turn delta this block only read a cache; now it iterates and awaits,
1393
- // so the whole thing needs the guard, not just `client.analyze`.
1394
- try {
1395
- for (const client of deadCodeClients) {
1396
- if (!client.detect(cwd)) {
1397
- reasons.push(`${client.id}:not_detected`);
1398
- continue;
1399
- }
1400
- if (![...modifiedFiles()].some((f) => client.owns(f))) {
1401
- reasons.push(`${client.id}:no_owned_files`);
1402
- continue;
1403
- }
1404
- const cacheKey = `dead-code-${client.id}`;
1405
- const prev = cacheManager.readCache(cacheKey, cwd);
1406
- // Back off after a timeout/kill so an unresponsive scanner cannot cost
1407
- // every later turn its full analysis budget (mirrors knip).
1408
- if (prev &&
1409
- !prev.data.success &&
1410
- /(timed out|killed|SIGTERM|SIGKILL|SIGABRT)/i.test(prev.data.summary)) {
1411
- dbg(`turn_end: skipping dead-code after failure: ${prev.data.summary}`);
1412
- deadCodeMeta.skipped = true;
1413
- reasons.push(`${client.id}:backoff:${prev.data.summary}`);
1414
- continue;
1415
- }
1416
- const startMs = Date.now();
1417
- try {
1418
- const result = await client.analyze(cwd);
1419
- const durationMs = Date.now() - startMs;
1420
- // Never overwrite a good scan with a failure (#925, #1467): a
1421
- // vulture timeout on one .py turn would otherwise evict the
1422
- // session_start scan, and the backoff above would then latch
1423
- // off the poisoned record.
1424
- if (wouldPoisonCache(prev, result)) {
1425
- dbg(`turn_end: keeping last good dead-code(${client.id}) cache; this run failed: ${result.summary}`);
1426
- deadCodeMeta.cacheKept = true;
1427
- }
1428
- else {
1429
- cacheManager.writeCache(cacheKey, result, cwd, {
1430
- scanDurationMs: durationMs,
1431
- });
1432
- }
1433
- // One event per cross-file scan (AGENTS.md) — the per-turn scan is
1434
- // now the primary path, so dead-code.log must see it too.
1435
- logDeadCodeScan({
1436
- language: client.language,
1437
- success: result.success,
1438
- cached: false,
1439
- unusedExports: result.unusedExports.length,
1440
- unusedFiles: result.unusedFiles.length,
1441
- unusedDeps: result.unusedDeps.length,
1442
- unlistedDeps: result.unlistedDeps.length,
1443
- durationMs: result.durationMs ?? durationMs,
1444
- ...(!result.success && { reason: result.summary }),
1445
- });
1446
- deadCodeMeta.success = result.success;
1447
- if (!result.success) {
1448
- reasons.push(`${client.id}:scan_failed:${result.summary}`);
1449
- continue;
1450
- }
1451
- deadCodeMeta.totalIssues =
1452
- (deadCodeMeta.totalIssues ?? 0) + deadCodeIssues(result).length;
1453
- // No baseline means every finding looks new. Report nothing rather
1454
- // than blame the edit for the whole project's pre-existing debt.
1455
- if (!prev?.data.success) {
1456
- reasons.push(`${client.id}:no_previous_scan`);
1457
- continue;
1458
- }
1459
- const prevKeys = new Set(deadCodeIssues(prev.data).map(deadCodeIssueKey));
1460
- const modified = modifiedFiles();
1461
- const newIssues = deadCodeIssues(result).filter((issue) => {
1462
- if (prevKeys.has(deadCodeIssueKey(issue)))
1463
- return false;
1464
- if (!issue.file)
1465
- return false;
1466
- return modified.has(resolveRunnerPath(cwd, issue.file));
1467
- });
1468
- if (newIssues.length === 0) {
1469
- reasons.push(`${client.id}:clean`);
1470
- continue;
1471
- }
1472
- newIssueTotal += newIssues.length;
1473
- projectDiagnosticsDelta.push(...newIssues.map((issue) => deadCodeIssueToProjectDiagnostic(cwd, issue, result.language)));
1474
- projectDiagnosticsSources.add("dead-code");
1475
- // @delivery-surface: runtime-turn:dead-code-advisory
1476
- advisoryParts.push(formatDeadCodeDelta(newIssues, result.language));
1477
- }
1478
- catch (err) {
1479
- dbg(`turn_end: dead-code(${client.id}) failed: ${err}`);
1480
- reasons.push(`${client.id}:threw`);
1481
- }
1482
- }
1483
- }
1484
- catch (err) {
1485
- dbg(`turn_end: dead-code block failed: ${err}`);
1486
- reasons.push("block_threw");
1487
- }
1488
- deadCodeMeta.newIssues = newIssueTotal;
1489
- if (reasons.length > 0)
1490
- deadCodeMeta.reason = reasons.join(",");
1491
- }
1492
- logLatency({
1493
- type: "phase",
1494
- toolName: "turn_end",
1495
- filePath: cwd,
1496
- phase: "dead-code",
1497
- durationMs: Date.now() - tDeadCode,
1498
- metadata: deadCodeMeta,
1499
- });
1500
- // #1617: running total of findings this turn's advisory/blocker sections
1501
- // dropped because an agent/user marked them false-positive/won't-fix —
1502
- // the #1616 suppressed-bucket rule applied to turn_end's own reporting
1503
- // lanes, so a mark's effect stays visible even though the finding itself
1504
- // no longer appears above. Review-round F4 (#1625): kept per-lane, not
1505
- // just a bare total, so the eventual trace says WHICH lane's marks did
1506
- // the suppressing.
1507
- let dispositionSuppressedTotal = 0;
1508
- const dispositionSuppressedByLane = {};
1509
- function recordDispositionSuppressed(lane, count) {
1510
- if (count <= 0)
1511
- return;
1512
- dispositionSuppressedTotal += count;
1513
- dispositionSuppressedByLane[lane] =
1514
- (dispositionSuppressedByLane[lane] ?? 0) + count;
1515
- }
1516
- // govulncheck — surface session_start-cached Go CVE findings as advisory.
1517
- // No per-turn re-run in this slice; the cache refreshes at next session_start.
1518
- const govCacheEntry = cacheManager.readCache("govulncheck", cwd);
1519
- // #1622: govulncheck renders a call site as `file:line`, and the cache is a
1520
- // session_start snapshot — the same stale-line shape as gitleaks, one tier
1521
- // lower. A CVE is pinned by go.mod, NOT by the call site, so neither an edit
1522
- // nor a deletion may drop it: `onMissing: "demote"` routes a vanished traced
1523
- // file into the same arm as an edited one. This gate only ever decides
1524
- // whether the cited LINE is still worth printing. (Review round H1: the first
1525
- // cut let a deleted trace file drop the CVE, contradicting this comment, and
1526
- // `citedPath` reads only the FIRST filename frame — so one deleted file in a
1527
- // long trace silently killed a CVE that go.mod still pins.)
1528
- const govGate = gateFindingsByPathFreshness({
1529
- store: "govulncheck",
1530
- findings: govCacheEntry?.data?.findings ?? [],
1531
- cwd,
1532
- scannedAt: govCacheEntry?.data?.scannedAt,
1533
- citedPath: (finding) => finding.trace.find((t) => t.filename)?.filename,
1534
- onMissing: "demote",
1535
- });
1536
- const govStale = new Set(govGate.stale);
1537
- // #1625 review round: the #1622 freshness gate runs FIRST — the disposition
1538
- // filter's anchor is derived from each finding's post-demotion identity
1539
- // (this array is already the gate's live+stale partition, never the raw
1540
- // pre-gate cache). #1627's own post-gate guard (`if (govFindings.length)`)
1541
- // and this round's disposition guard are the SAME guard — compose them,
1542
- // never fall back to a raw-cache-length check (that would print the header
1543
- // with zero rows beneath it whenever either filter empties the list).
1544
- const govFiltered = filterFindingsByDisposition([...govGate.live, ...govGate.stale], cwd, (f) => govulncheckFindingToProjectDiagnostic(cwd, f));
1545
- recordDispositionSuppressed("govulncheck", govFiltered.suppressed);
1546
- const govFindings = govFiltered.kept;
1547
- if (govFindings.length) {
1548
- const findings = govFindings.slice(0, 5);
1549
- let report = "🛡️ Go CVEs reachable from this code (govulncheck) — upgrade where possible:\n";
1550
- for (const f of findings) {
1551
- const callSite = f.trace.find((t) => t.filename);
1552
- const stale = govStale.has(f);
1553
- const where = callSite?.filename
1554
- ? `${toRunnerDisplayPath(cwd, callSite.filename)}${!stale && callSite.line ? `:${callSite.line}` : ""}${stale ? ` ${STALE_LINE_MARKER}` : ""}`
1555
- : (f.module ?? f.packageName ?? "(module)");
1556
- const fix = f.fixedVersion
1557
- ? ` — upgrade to ${f.fixedVersion} or later`
1558
- : " — no fix yet, track upstream";
1559
- report += ` ${f.osv} (${where})${fix}\n`;
1560
- }
1561
- if (govFindings.length > findings.length) {
1562
- report += ` … and ${govFindings.length - findings.length} more\n`;
1563
- }
1564
- // @delivery-surface: runtime-turn:govulncheck-advisory
1565
- advisoryParts.push(report);
1566
- }
1567
- const trivyCacheEntry = cacheManager.readCache("trivy", cwd);
1568
- // Secrets — UNIFIED surfacing (#131 Mode 3). gitleaks, trivy secret, and the
1569
- // ast-grep hardcoded-secret rules can each flag the SAME line with different
1570
- // rule ids, which the rule-keyed diagnostic dedup can't collapse. Collapse by
1571
- // location so a committed/hardcoded secret is reported ONCE (with combined
1572
- // provenance) — a blocker, since credentials need rotation before merge.
1573
- const gitleaksData = cacheManager.readCache("gitleaks", cwd)?.data;
1574
- const trivySecretsData = trivyCacheEntry?.data;
1575
- // Gitleaks deliberately scans gitignored local files and nested repositories
1576
- // so an explicit security audit can still inspect them. The adapter is the
1577
- // source of truth for whether a finding belongs in a blocking delivery lane;
1578
- // filter here before freshness handling so demoted findings cannot leak into
1579
- // either the blocker or stale-secret turn context.
1580
- const boundedClassification = await bounded(classifyAndFilterFindings(gitleaksData?.findings ?? [], cwd), {
1581
- ms: HOOK_WALL_BUDGET_MS.turn_end,
1582
- signal: deps.signal,
1583
- hook: "turn_end",
1584
- label: "classifyAndFilterFindings",
1585
- });
1586
- const classifiedGitleaksFindings = boundedClassification ?? gitleaksData?.findings ?? [];
1587
- if (boundedClassification === undefined) {
1588
- recordDegradationOnce({
1589
- kind: "gitleaks_classification_timeout",
1590
- subject: cwd,
1591
- reason: "gitleaks classification exceeded the turn_end budget; retained raw findings to fail open",
1592
- });
1593
- }
1594
- const blockingGitleaksFindings = classifiedGitleaksFindings.filter((finding) => gitleaksFindingToProjectDiagnostic(cwd, finding).semantic === "blocking");
1595
- // #1461 slice 1 (#1460): the gitleaks cache is TTL-only, so a finding for a
1596
- // file deleted after the scan is still served as a 🔴 blocker for the rest
1597
- // of the 30-minute window — the live case, and 119 of 126 findings in
1598
- // pi-lens's own cache. This read is the single agent-facing consumer of that
1599
- // store (session_start's read only decides whether to re-scan; the
1600
- // project-diagnostics path re-scans fresh and reconciles at load), so the
1601
- // drop belongs here, before the findings enter the shared secret pipeline.
1602
- // #1622 extends that gate from existence to freshness, and adds trivy
1603
- // secrets — the sibling store with the identical shape. A cited file edited
1604
- // after the scan keeps its finding but loses its line number: the credential
1605
- // may still be there, just not where the snapshot says. Dropping instead
1606
- // would let any edit — malicious or accidental — mute a real secret.
1607
- const gitleaksGate = gateFindingsByPathFreshness({
1608
- store: "gitleaks",
1609
- findings: blockingGitleaksFindings,
1610
- cwd,
1611
- scannedAt: gitleaksData?.scannedAt,
1612
- citedPath: (finding) => finding.file,
1613
- });
1614
- const trivySecretsGate = gateFindingsByPathFreshness({
1615
- store: "trivy-secrets",
1616
- findings: trivySecretsData?.secrets ?? [],
1617
- cwd,
1618
- scannedAt: trivySecretsData?.scannedAt,
1619
- citedPath: (finding) => finding.file,
1620
- });
1621
- // #1617: THE bug this issue exists for — gitleaks findings never passed
1622
- // through `applyDispositions`, so an agent-marked false-positive/won't-fix
1623
- // re-reported as a 🔴 STOP blocker on every turn. Filter through the SAME
1624
- // `gitleaksFindingToProjectDiagnostic` identity `lens_diagnostics
1625
- // mode=full` surfaces (tool="gitleaks", rule="gitleaks:<ruleId>", the
1626
- // exact "Potential secret: …" message) so a mark made against what the
1627
- // agent was shown is honored here too.
1628
- //
1629
- // #1625 review round: filtered AFTER the #1622 freshness gate above — the
1630
- // anchor is derived from each finding's post-demotion identity, never the
1631
- // raw pre-gate cache. Applied to BOTH `gitleaksGate.live` AND
1632
- // `gitleaksGate.stale`: `staleSecretEntries` below derives from the stale
1633
- // arm, and an fp-marked finding that later goes stale must not reappear
1634
- // there — a suppression escape (and a double count against
1635
- // `dispositionSuppressedTotal`) that a live-only filter would have missed.
1636
- //
1637
- // #1628: trivy-secret findings get the SAME treatment, now that
1638
- // `trivySecretFindingToProjectDiagnostic` (project-diagnostics/runner-
1639
- // adapters/trivy.ts) gives them a `lens_diagnostics`-surfaced identity
1640
- // (tool="trivy", rule="trivy-secret:<ruleId>") to anchor a mark against —
1641
- // same pattern as gitleaks above, applied to both the live and stale arms
1642
- // for the same reason.
1643
- //
1644
- // ast-grep secret findings need no filtering here — they already went
1645
- // through dispatch's applyDispositions before reaching
1646
- // `peekActionableWarnings()`.
1647
- const gitleaksLiveFiltered = filterFindingsByDisposition(gitleaksGate.live, cwd, (f) => gitleaksFindingToProjectDiagnostic(cwd, f));
1648
- const gitleaksStaleFiltered = filterFindingsByDisposition(gitleaksGate.stale, cwd, (f) => gitleaksFindingToProjectDiagnostic(cwd, f));
1649
- recordDispositionSuppressed("gitleaks", gitleaksLiveFiltered.suppressed + gitleaksStaleFiltered.suppressed);
1650
- const trivySecretsLiveFiltered = filterFindingsByDisposition(trivySecretsGate.live, cwd, (f) => trivySecretFindingToProjectDiagnostic(cwd, f));
1651
- const trivySecretsStaleFiltered = filterFindingsByDisposition(trivySecretsGate.stale, cwd, (f) => trivySecretFindingToProjectDiagnostic(cwd, f));
1652
- recordDispositionSuppressed("trivy-secrets", trivySecretsLiveFiltered.suppressed + trivySecretsStaleFiltered.suppressed);
1653
- const astSecretWarnings = runtime
1654
- .peekActionableWarnings()
1655
- .filter(isSecretWarning);
1656
- const sessionSecrets = dedupeSecretFindings([
1657
- ...fromGitleaks(gitleaksLiveFiltered.kept),
1658
- ...fromTrivySecrets(trivySecretsLiveFiltered.kept),
1659
- ]);
1660
- // Demoted secrets are addressed by FILE, never by line — the line is the one
1661
- // field the edit invalidated. Rule id and source survive it and must be
1662
- // carried through (review round M1): an agent triages an `aws-access-token`
1663
- // differently from a low-confidence `generic-api-key`, and cannot do that
1664
- // from a bare path. Deduped on file+rule+source so a file with twenty stale
1665
- // hits of one rule is named once.
1666
- const staleSecretEntries = [
1667
- ...gitleaksStaleFiltered.kept.map((f) => ({
1668
- file: toRunnerDisplayPath(cwd, f.file),
1669
- rule: f.ruleId,
1670
- source: "gitleaks",
1671
- })),
1672
- ...trivySecretsStaleFiltered.kept.map((f) => ({
1673
- file: toRunnerDisplayPath(cwd, f.file),
1674
- rule: f.ruleId,
1675
- source: "trivy",
1676
- })),
1677
- ];
1678
- const staleSecrets = [
1679
- ...new Map(staleSecretEntries.map((e) => [`${e.file}|${e.rule}|${e.source}`, e])).values(),
1680
- ];
1681
- // Locations already surfaced as session-scan secret blockers — used to enrich
1682
- // provenance where ast-grep agrees and to suppress the duplicate ast-grep copy
1683
- // from the actionable-warnings advisory below.
1684
- const secretBlockedLocations = new Set(sessionSecrets.map((f) => secretLocationKey(f.file, f.line)));
1685
- if (sessionSecrets.length) {
1686
- // Fold in ast-grep provenance ONLY where it coincides with a session
1687
- // secret — don't promote ast-grep-only findings out of their advisory tier.
1688
- const enriched = dedupeSecretFindings([
1689
- ...sessionSecrets,
1690
- ...fromAstGrepWarnings(astSecretWarnings).filter((a) => secretBlockedLocations.has(secretLocationKey(a.file, a.line))),
1691
- ]);
1692
- const shown = enriched.slice(0, 5);
1693
- let report = "🔴 STOP — hardcoded secrets detected. Rotate the credentials and remove them from source:\n";
1694
- for (const f of shown) {
1695
- const where = `${toRunnerDisplayPath(cwd, f.file)}:${f.line}`;
1696
- report += ` ${where} — ${f.rule} [${f.sources.join(" + ")}]${f.description ? `: ${f.description}` : ""}\n`;
1697
- }
1698
- if (enriched.length > shown.length) {
1699
- report += ` … and ${enriched.length - shown.length} more\n`;
1700
- }
1701
- // @delivery-surface: runtime-turn:secrets-gitleaks,runtime-turn:secrets-trivy
1702
- blockerParts.push(report);
1703
- }
1704
- if (staleSecrets.length) {
1705
- // Its OWN tier, never `advisoryParts` (review round M2). The advisory tier
1706
- // is labelled "no action required this turn", which would sit directly
1707
- // above copy telling the agent to re-scan — a section that contradicts its
1708
- // own heading. This preamble is imperative because the action is real: the
1709
- // finding is unverified, not dismissed.
1710
- const shown = staleSecrets.slice(0, 5);
1711
- let report = `🔑 ACTION NEEDED — secrets were flagged in files that changed after the scan. ${STALE_LINE_MARKER}\n` +
1712
- "The cached line numbers are no longer trustworthy, so they are withheld. Re-run a secrets scan to confirm or clear these:\n";
1713
- for (const entry of shown) {
1714
- report += ` ${entry.file} — ${entry.rule} [${entry.source}]\n`;
1715
- }
1716
- if (staleSecrets.length > shown.length) {
1717
- report += ` … and ${staleSecrets.length - shown.length} more\n`;
1718
- }
1719
- // @delivery-surface: runtime-turn:stale-secrets-tier
1720
- staleSecretParts.push(report);
1721
- }
1722
- // trivy — surface session_start-cached dependency CVEs (#131, Phase 1).
1723
- // CRITICAL is a blocker (a known-exploitable CVE in a shipped dep is real
1724
- // production risk); HIGH/MEDIUM/LOW are advisory. The agent gets the upgrade
1725
- // target as a hint and decides — we never auto-edit lockfiles.
1726
- //
1727
- // #1634: these three trivy reports (critical blocker, non-critical
1728
- // advisory, license advisory below) name a PACKAGE, not a file:line — there
1729
- // is no cited path for `gateFindingsByPathFreshness` to stat, so unlike the
1730
- // secrets/govulncheck stores above this store cannot be freshness-GATED.
1731
- // It is the delivery gate's explicit-label escape hatch instead
1732
- // (`clients/finding-delivery-gate.ts`, surfaces `runtime-turn:trivy-*`):
1733
- // the session_start cache can be arbitrarily old, so its age is stated
1734
- // plainly rather than presenting a CRITICAL blocker as if it were current.
1735
- // This runs on top of (not instead of) #1625's disposition filter below —
1736
- // a suppressed finding never reaches this render at all, so the two only
1737
- // ever compose.
1738
- const trivyAgeLabel = formatCacheAgeLabel(trivyCacheEntry?.data?.scannedAt);
1739
- const trivyFindingsFiltered = filterFindingsByDisposition(trivyCacheEntry?.data?.findings ?? [], cwd, (f) => trivyFindingToProjectDiagnostic(cwd, f));
1740
- recordDispositionSuppressed("trivy", trivyFindingsFiltered.suppressed);
1741
- if (trivyFindingsFiltered.kept.length) {
1742
- const all = trivyFindingsFiltered.kept;
1743
- const critical = all.filter((f) => f.severity === "CRITICAL");
1744
- const advisory = all.filter((f) => f.severity !== "CRITICAL");
1745
- const fmt = (f) => {
1746
- const pkg = f.installedVersion
1747
- ? `${f.pkgName}@${f.installedVersion}`
1748
- : f.pkgName;
1749
- const fix = f.fixedVersion
1750
- ? ` — upgrade to ${f.fixedVersion} or later`
1751
- : " — no fix yet, track upstream";
1752
- return ` ${f.vulnerabilityId} (${pkg})${fix}\n`;
1753
- };
1754
- if (critical.length) {
1755
- const shown = critical.slice(0, 5);
1756
- let report = `🔴 STOP — CRITICAL dependency CVEs (trivy, ${trivyAgeLabel}). Upgrade before shipping:\n`;
1757
- for (const f of shown)
1758
- report += fmt(f);
1759
- if (critical.length > shown.length) {
1760
- report += ` … and ${critical.length - shown.length} more\n`;
1761
- }
1762
- // @delivery-surface: runtime-turn:trivy-critical-blocker
1763
- blockerParts.push(report);
1764
- }
1765
- if (advisory.length) {
1766
- const shown = advisory.slice(0, 5);
1767
- let report = `🛡️ Dependency CVEs (trivy, ${trivyAgeLabel}) — upgrade where possible:\n`;
1768
- for (const f of shown)
1769
- report += fmt(f);
1770
- if (advisory.length > shown.length) {
1771
- report += ` … and ${advisory.length - shown.length} more\n`;
1772
- }
1773
- // @delivery-surface: runtime-turn:trivy-cve-advisory
1774
- advisoryParts.push(report);
1775
- }
1776
- }
1777
- // trivy — dependency license risk (#131 Mode 4). Advisory only: a copyleft /
1778
- // restricted license in a proprietary tree is a compliance signal, not a
1779
- // build break. Surfaced from the same cached `trivy fs` pass — same #1634
1780
- // explicit-label rationale as the CVE reports above (no cited path to gate).
1781
- const licenses = trivyCacheEntry?.data?.licenses ?? [];
1782
- if (licenses.length) {
1783
- const shown = licenses.slice(0, 5);
1784
- let report = `📜 Dependency license risk (trivy, ${trivyAgeLabel}) — review for compliance:\n`;
1785
- for (const l of shown) {
1786
- const cat = l.category ? `, ${l.category}` : "";
1787
- report += ` ${l.pkgName} — ${l.license} (${l.severity}${cat})\n`;
1788
- }
1789
- if (licenses.length > shown.length) {
1790
- report += ` … and ${licenses.length - shown.length} more\n`;
1791
- }
1792
- // @delivery-surface: runtime-turn:trivy-license-advisory
1793
- advisoryParts.push(report);
1794
- }
1795
- // #1616 suppressed-bucket rule: surface the running disposition-drop total
1796
- // as its own advisory line so a mark's effect is visible, not a silent
1797
- // absence — trace, not a vanish.
1798
- if (dispositionSuppressedTotal > 0) {
1799
- // Review-round F4 (#1625): per-lane attribution, e.g.
1800
- // "gitleaks 2, govulncheck 1" — not just a bare total.
1801
- const byLane = Object.entries(dispositionSuppressedByLane)
1802
- .map(([lane, count]) => `${lane} ${count}`)
1803
- .join(", ");
1804
- // @delivery-surface: runtime-turn:disposition-suppressed-notice
1805
- advisoryParts.push(`suppressed by disposition: ${dispositionSuppressedTotal} finding(s) ` +
1806
- `dropped from this turn's gitleaks/govulncheck/trivy sections (${byLane}) ` +
1807
- "(marked false-positive or won't-fix).");
1808
- }
1809
- const t3 = Date.now();
1810
- let madgeStats;
1811
- // Off by default (#766): this pass only writes debug output, and user-facing
1812
- // madge diagnostics come from the session-start `madge` cache + the
1813
- // `lens_diagnostics` extractor. Enabled with `--lens-turn-end-madge` /
1814
- // `turnEnd.madge.enabled=true` for those who want the per-edit circular note.
1815
- if (getFlag("lens-turn-end-madge") && (await depChecker.ensureAvailable())) {
1816
- const madgeFiles = cacheManager.getFilesForMadge(cwd);
1817
- if (madgeFiles.length > 0) {
1818
- dbg(`turn_end: madge checking ${madgeFiles.length} file(s) for circular deps`);
1819
- // Checked concurrently (bounded) rather than one `await` per file —
1820
- // the shared circular-dep state update is deferred/folded inside
1821
- // checkFilesBatch so concurrent spawns can't clobber each other (#766).
1822
- const absFiles = madgeFiles.map((file) => path.resolve(cwd, file));
1823
- const batch = await depChecker.checkFilesBatch(absFiles, cwd);
1824
- const depResults = batch.results;
1825
- madgeStats = batch.stats;
1826
- for (const file of madgeFiles) {
1827
- const absPath = path.resolve(cwd, file);
1828
- const depResult = depResults.get(absPath);
1829
- if (!depResult)
1830
- continue;
1831
- if (depResult.localSkips && depResult.localSkips > 0) {
1832
- // Not silent: a skipped LOCAL import means madge couldn't resolve
1833
- // it into the graph, so a cycle through it would be missed.
1834
- dbg(`turn_end: madge skipped ${depResult.localSkips} local file(s) resolving ${file} — possible silent cycle-miss`);
1835
- }
1836
- if (depResult.hasCircular && depResult.circular.length > 0) {
1837
- // Whole-project circular deps are surfaced in lens_diagnostics via the
1838
- // session-start `madge` cache + extractor; this per-file turn-end pass
1839
- // only logs (blockers-only mode suppresses circular-dep notes).
1840
- dbg(`turn_end: circular dependency note for ${file} (suppressed in blockers-only mode)`);
1841
- }
1842
- }
1843
- }
1844
- }
1845
- logLatency({
1846
- type: "phase",
1847
- toolName: "turn_end",
1848
- filePath: cwd,
1849
- phase: "madge",
1850
- durationMs: Date.now() - t3,
1851
- // A ~0ms entry with no metadata is indistinguishable from "ran and was
1852
- // fast" when re-analyzing the #766 tail — mark the skipped case.
1853
- metadata: madgeStats ?? { skipped: true },
1854
- });
1855
- // --- Test runner: fire once per turn after all edits are done ---
1856
- // Runs for each unique test target across modified files; results remain in
1857
- // the pull-diagnostics cache and are delivered after the agent settles.
1858
- if (!getFlag("no-tests") && files.length > 0) {
1859
- const seen = new Set();
1860
- // `strategy` is widened by one value the resolver itself cannot produce:
1861
- // a target carried over from the previous turn's cut batch (#2522 R2 F1).
1862
- const targets = [];
1863
- // #628: also target the test companions of this turn's cascade neighbors
1864
- // (files that import an edited file) — a neighbor's own tests can break
1865
- // even though the neighbor's source wasn't touched. Reuses `cascadeResults`,
1866
- // already computed above (from the same #450 deferred-cascade drain) for the
1867
- // LSP cascade-diagnostics merge — no second reverse-dependency walk, and the
1868
- // neighbor set inherits whatever budget the cascade compute already applied
1869
- // (CASCADE_NEIGHBOUR_BUDGET), so this can't turn into unbounded per-edit work.
1870
- const candidates = [];
1871
- const seenCandidateKeys = new Set();
1872
- for (const file of files) {
1873
- const abs = resolveRunnerPath(cwd, file);
1874
- const key = normalizeMapKey(abs);
1875
- if (seenCandidateKeys.has(key))
1876
- continue;
1877
- seenCandidateKeys.add(key);
1878
- candidates.push({ display: file, abs, isNeighbor: false });
1879
- }
1880
- for (const result of cascadeResults) {
1881
- for (const neighbor of result.neighbors) {
1882
- const abs = path.isAbsolute(neighbor.filePath)
1883
- ? neighbor.filePath
1884
- : resolveRunnerPath(cwd, neighbor.filePath);
1885
- const key = normalizeMapKey(abs);
1886
- if (seenCandidateKeys.has(key))
1887
- continue;
1888
- seenCandidateKeys.add(key);
1889
- candidates.push({ display: neighbor.filePath, abs, isNeighbor: true });
1890
- }
1891
- }
1892
- // #2522 review round 2, F1: the previous turn's unfinished targets go
1893
- // FIRST — ahead of failed-first/related/self — under the same
1894
- // TEST_RUNNER_MAX_TARGETS cap, so a batch that keeps getting cut makes
1895
- // progress instead of re-running whatever this turn happened to touch
1896
- // and dropping the remainder again. The list is consumed here (written
1897
- // back empty below) so a target can never be carried forever.
1898
- const priorTestCache = cacheManager.readCache("test-runner-findings", cwd)?.data;
1899
- const carriedDeferred = priorTestCache?.deferredTargets ?? [];
1900
- // #2522 review round 3, F5: a deferral list belongs to the session that
1901
- // cut it. `runtime.telemetrySessionId` is the same identity the batch
1902
- // stamps its own entries with below (`firedSessionId`), so this is a
1903
- // single comparison against one source of truth.
1904
- const turnSessionId = sessionId ?? runtime.telemetrySessionId;
1905
- const isThisSession = (entry) => entry.sessionId === turnSessionId;
1906
- const entryPathKey = (entry) => normalizeMapKey(path.resolve(cwd, entry.testFile));
1907
- /**
1908
- * #2522 review round 3, F1: the retirement, carried forward.
1909
- *
1910
- * Round 2 announced the retirement and dropped it on the floor, so the
1911
- * candidate loop below re-resolved the very same file through
1912
- * `related`/`self` in the SAME turn and ran it anyway with a fresh
1913
- * (undefined) attempt count. Steady state was a 3-turn cycle that spawned
1914
- * and cut the slow suite on every turn. The retired set is persisted on
1915
- * the record instead.
1916
- *
1917
- * Round 4, I1: this is a SELECTION filter and nothing more. Round 3 also
1918
- * wrote the filtered list straight back, so a turn taken by the other
1919
- * entry point into the same project — `clients/mcp/session.ts` passes no
1920
- * `sessionId` and falls back to `telemetrySessionId`, while `index.ts`
1921
- * passes pi's stable id — erased the entries belonging to the other
1922
- * route. Both lists survive every write now; see `writeTestFindings`.
1923
- */
1924
- const retiredCarry = (priorTestCache?.retiredTargets ?? []).filter(isThisSession);
1925
- const retiredKeys = new Set(retiredCarry.map(entryPathKey));
1926
- /**
1927
- * Deferral entries of THIS session that this turn has settled: dispatched
1928
- * into `targets`, retired at the cap, or dropped as unrunnable. Only
1929
- * these may leave the persisted list, and only when this turn's own write
1930
- * is not re-asserting them (a cut target comes straight back with
1931
- * `attempts + 1`).
1932
- */
1933
- const resolvedDeferralKeys = new Set();
1934
- /**
1935
- * #2522 review round 4, I5: entries this turn could not fit under
1936
- * `TEST_RUNNER_MAX_TARGETS`. They were not run, so they keep their attempt
1937
- * count and stay on the list — round 3 folded the over-cap case in with
1938
- * "no longer resolves" and dropped them outright, so a carry larger than
1939
- * the cap lost its tail every turn under a dbg line that said the files
1940
- * were gone.
1941
- */
1942
- const heldDeferred = [];
1943
- /**
1944
- * The ONE writer of `test-runner-findings` in this block, so the rules
1945
- * about what survives a write are stated once instead of at six call sites
1946
- * that a seventh would silently not join (#2522 review round 3, F1; same
1947
- * net-count rule as `retireTestFindings` in `runtime-context.ts`).
1948
- *
1949
- * Three rules, all of them round-4 invariants:
1950
- * - I1: the LIVE record is re-read here and merged into, never replaced.
1951
- * A row belonging to another session is untouchable — this turn may
1952
- * ignore it, never delete it.
1953
- * - I2: every write merges. A batch that publishes clean, a batch that
1954
- * publishes failures and a superseded batch all go through this, so
1955
- * none of them can overwrite a concurrent batch's cut set. Removal is
1956
- * narrow by construction: an entry leaves only if it is this session's,
1957
- * this turn settled it, and this write is not re-asserting it.
1958
- * - the persisted lists are bounded (`TEST_RUNNER_MAX_PERSISTED_TARGETS`)
1959
- * with this session's rows ordered LAST, so the bound sheds foreign
1960
- * rows first and can never evict what this turn just recorded.
1961
- */
1962
- const boundPersisted = (entries, label) => {
1963
- const ordered = [
1964
- ...entries.filter((entry) => !isThisSession(entry)),
1965
- ...entries.filter(isThisSession),
1966
- ];
1967
- if (ordered.length <= TEST_RUNNER_MAX_PERSISTED_TARGETS)
1968
- return ordered;
1969
- 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)`);
1970
- return ordered.slice(-TEST_RUNNER_MAX_PERSISTED_TARGETS);
1971
- };
1972
- const writeTestFindings = (record, ownDeferred) => {
1973
- const live = cacheManager.readCache("test-runner-findings", cwd)?.data;
1974
- const own = mergeDeferredTargets(heldDeferred, ownDeferred);
1975
- const ownKeys = new Set(own.map(entryPathKey));
1976
- const merged = mergeDeferredTargets(live?.deferredTargets ?? [], own).filter((entry) => {
1977
- if (!isThisSession(entry))
1978
- return true;
1979
- const key = entryPathKey(entry);
1980
- return !resolvedDeferralKeys.has(key) || ownKeys.has(key);
1981
- });
1982
- cacheManager.writeCache("test-runner-findings", {
1983
- ...record,
1984
- deferredTargets: boundPersisted(merged, "deferred"),
1985
- retiredTargets: boundPersisted(mergeDeferredTargets(live?.retiredTargets ?? [], retiredCarry), "retired"),
1986
- }, cwd);
1987
- };
1988
- let deadDeferred = 0;
1989
- let heldOverCap = 0;
1990
- let foreignDeferred = 0;
1991
- let redispatchedDeferred = 0;
1992
- let retiredThisTurn = 0;
1993
- for (const carried of carriedDeferred) {
1994
- const testFile = path.resolve(cwd, carried.testFile);
1995
- const carriedKey = normalizeMapKey(testFile);
1996
- // I1: another session still owes this run. Skip it, leave it on the
1997
- // record — `writeTestFindings` never removes a row this turn does not
1998
- // own.
1999
- if (!isThisSession(carried)) {
2000
- foreignDeferred++;
2001
- continue;
2002
- }
2003
- if (seen.has(carriedKey)) {
2004
- resolvedDeferralKeys.add(carriedKey);
2005
- continue;
2006
- }
2007
- const attempts = carried.attempts ?? 0;
2008
- if (attempts >= TEST_RUNNER_MAX_DEFERRALS) {
2009
- resolvedDeferralKeys.add(carriedKey);
2010
- if (!retiredKeys.has(carriedKey)) {
2011
- retiredKeys.add(carriedKey);
2012
- retiredThisTurn++;
2013
- retiredCarry.push({
2014
- testFile,
2015
- runner: carried.runner,
2016
- attempts,
2017
- sessionId: turnSessionId,
2018
- });
2019
- // Never silent, and counted rather than once-per-subject: this is
2020
- // the ledger entry that names WHICH suite is too slow to belong in
2021
- // a per-turn batch at all.
2022
- incrementDegradationCount({
2023
- kind: "test-runner-batch-capped",
2024
- subject: `${cwd}:deferral-exhausted`,
2025
- 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`,
2026
- });
2027
- 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`);
2028
- }
2029
- continue;
2030
- }
2031
- // A RunnerConfig carries functions, so the cache stores the runner KEY
2032
- // and the config is re-resolved here from the single registry.
2033
- const config = RUNNERS[carried.runner];
2034
- if (!config ||
2035
- isExcludedTestTarget(testFile, cwd) ||
2036
- !fs.existsSync(testFile)) {
2037
- resolvedDeferralKeys.add(carriedKey);
2038
- deadDeferred++;
2039
- continue;
2040
- }
2041
- if (targets.length >= TEST_RUNNER_MAX_TARGETS) {
2042
- // I5: not settled — held, with `attempts` UNCHANGED. It was never
2043
- // dispatched, so charging it toward retirement would retire a suite
2044
- // this turn simply had no room for.
2045
- heldOverCap++;
2046
- heldDeferred.push({
2047
- testFile,
2048
- runner: carried.runner,
2049
- sourceFile: carried.sourceFile ?? testFile,
2050
- attempts,
2051
- sessionId: turnSessionId,
2052
- });
2053
- continue;
2054
- }
2055
- resolvedDeferralKeys.add(carriedKey);
2056
- seen.add(carriedKey);
2057
- redispatchedDeferred++;
2058
- targets.push({
2059
- testFile,
2060
- sourceFile: carried.sourceFile ?? testFile,
2061
- runner: carried.runner,
2062
- config,
2063
- strategy: "deferred",
2064
- deferralAttempts: attempts,
2065
- });
2066
- dbg(`turn_end: re-running deferred test target ${path.relative(cwd, testFile)} (${carried.runner}) from the previous turn's cut batch`);
2067
- }
2068
- if (deadDeferred > 0) {
2069
- dbg(`turn_end: dropped ${deadDeferred} deferred test target(s) that no longer resolve (missing file, excluded, or unknown runner)`);
2070
- }
2071
- if (heldOverCap > 0) {
2072
- 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`);
2073
- }
2074
- if (foreignDeferred > 0) {
2075
- dbg(`turn_end: ignored ${foreignDeferred} deferred test target(s) belonging to another session — left on the record for the session that cut them`);
2076
- }
2077
- if (carriedDeferred.length > 0) {
2078
- // #2522 review round 4, S1: the ONE pushed record for this path. The
2079
- // MCP / Stop-hook route calls `handleTurnEnd` with `dbg: noop`, so every
2080
- // line above is invisible there and the deferral machinery — the part
2081
- // that decides whether a suite runs at all — left no trace whatsoever
2082
- // on the route that fires it most.
2083
- logLatency({
2084
- type: "phase",
2085
- toolName: "turn_end",
2086
- filePath: cwd,
2087
- phase: "test_runner_deferral",
2088
- durationMs: 0,
2089
- metadata: {
2090
- carried: carriedDeferred.length,
2091
- redispatched: redispatchedDeferred,
2092
- heldOverCap,
2093
- retired: retiredThisTurn,
2094
- dropped: deadDeferred,
2095
- foreignSession: foreignDeferred,
2096
- sessionId: turnSessionId,
2097
- },
2098
- });
2099
- }
2100
- let overCapTargets = 0;
2101
- let missingTargetFiles = 0;
2102
- let excludedTargets = 0;
2103
- let retiredSkips = 0;
2104
- for (const { display, abs, isNeighbor } of candidates) {
2105
- const target = testRunnerClient.getTestRunTarget(abs, cwd, runtime.turnIndex);
2106
- const targetKey = target ? normalizeMapKey(target.testFile) : "";
2107
- if (target && !seen.has(targetKey)) {
2108
- seen.add(targetKey);
2109
- // #2522 review round 3, F1: THE gate that makes the deferral cap a
2110
- // cap. Whichever strategy produced this target — related, self,
2111
- // failed-first — a target already retired this session for
2112
- // outrunning the whole batch budget is not fired again. Without
2113
- // this the retire branch above was decorative: it logged, and the
2114
- // candidate loop three lines down re-resolved the same file and
2115
- // spawned it anyway, with its attempt counter reset to zero.
2116
- if (retiredKeys.has(targetKey)) {
2117
- retiredSkips++;
2118
- dbg(`turn_end: ${display} → test target retired earlier this session (outran the turn-end batch budget), skipping spawn (${path.relative(cwd, target.testFile)})`);
2119
- continue;
2120
- }
2121
- // #2522: built-in exclusion for turn-end SELECTION — a resolved
2122
- // target under an integration/e2e directory or naming convention
2123
- // is never auto-fired, whichever strategy (failed-first/related/
2124
- // self) produced it. No per-project config knob (maintainer
2125
- // decision); see `TURN_END_EXCLUDED_TEST_GLOBS`.
2126
- // LATENT HAZARD, deliberately left as-is: this `continue` skips the
2127
- // candidate entirely, including `retireMissingFailedTargets`. If an
2128
- // excluded target were ever seeded into the persisted failed set
2129
- // (it cannot be today — nothing writes that set except a runner
2130
- // RESULT, and an excluded target is never run, so it can never
2131
- // produce one), it would sit there unretired forever, chosen by
2132
- // the failed-first strategy on every turn and dropped here on
2133
- // every turn. Any future writer of the failed set must retire
2134
- // excluded entries at the write site, not here.
2135
- if (isExcludedTestTarget(target.testFile, cwd)) {
2136
- excludedTargets++;
2137
- dbg(`turn_end: ${display} → test target excluded (integration/e2e), skipping spawn (${path.relative(cwd, target.testFile)})`);
2138
- continue;
2139
- }
2140
- // #2504: a conventional target that no longer exists on disk still
2141
- // cost a full runner spawn, which came back "Test file not found"
2142
- // and was then dropped as an expected skip further down. 9 of the
2143
- // reported turn's 59 spawns were this. One statSync is cheaper
2144
- // than a vitest process by four orders of magnitude.
2145
- if (!fs.existsSync(target.testFile)) {
2146
- missingTargetFiles++;
2147
- dbg(`turn_end: ${display} → test file missing, skipping spawn (${path.relative(cwd, target.testFile)})`);
2148
- continue;
2149
- }
2150
- if (targets.length >= TEST_RUNNER_MAX_TARGETS) {
2151
- overCapTargets++;
2152
- continue;
2153
- }
2154
- targets.push({ ...target, sourceFile: abs });
2155
- dbg(`turn_end: ${display} → test ${target.runner} ${path.relative(cwd, target.testFile)} (${target.strategy}${isNeighbor ? ", cascade-neighbor" : ""})`);
2156
- }
2157
- else if (!target) {
2158
- dbg(`turn_end: ${display} → no test file found${isNeighbor ? " (cascade-neighbor)" : ""}`);
2159
- }
2160
- }
2161
- if (excludedTargets > 0) {
2162
- dbg(`turn_end: excluded ${excludedTargets} test target(s) under the built-in integration/e2e exclusion list`);
2163
- }
2164
- if (retiredSkips > 0) {
2165
- dbg(`turn_end: skipped ${retiredSkips} test target(s) retired earlier this session for outrunning the turn-end batch budget`);
2166
- }
2167
- if (missingTargetFiles > 0) {
2168
- dbg(`turn_end: skipped ${missingTargetFiles} test target(s) whose file no longer exists`);
2169
- }
2170
- if (overCapTargets > 0) {
2171
- // Never silent: the agent is told that some of this turn's tests were
2172
- // not run, rather than reading an all-green batch that covered part
2173
- // of the edit set.
2174
- recordDegradationOnce({
2175
- kind: "test-runner-batch-capped",
2176
- subject: cwd,
2177
- 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`,
2178
- });
2179
- dbg(`turn_end: test target count capped at ${TEST_RUNNER_MAX_TARGETS}, ${overCapTargets} skipped`);
2180
- }
2181
- if (targets.length > 0) {
2182
- for (const target of targets) {
2183
- target.fileSeqAtRun = runtime.getFileSeq(target.sourceFile);
2184
- }
2185
- dbg(`turn_end: firing ${targets.length} test target(s) async (non-blocking, max ${TEST_RUNNER_BATCH_CONCURRENCY} concurrent)`);
2186
- const firedAtTurn = runtime.turnIndex;
2187
- const firedSessionId = turnSessionId;
2188
- const testRunGeneration = (priorTestCache?.testRunGeneration ?? 0) + 1;
2189
- const provenanceFiles = [
2190
- ...candidates.map((candidate) => ({
2191
- path: candidate.abs,
2192
- role: "source",
2193
- })),
2194
- ...targets.map((target) => ({
2195
- path: target.testFile,
2196
- role: "test",
2197
- })),
2198
- ];
2199
- const launchedFrom = snapshotAdvisoryProvenance({
2200
- cwd,
2201
- runtime,
2202
- generation: testRunGeneration,
2203
- files: provenanceFiles,
2204
- });
2205
- writeTestFindings({
2206
- ...(priorTestCache ?? { content: "" }),
2207
- testRunGeneration,
2208
- },
2209
- // Consumed above into `targets` (or retired, or dropped). Anything
2210
- // this turn HELD is re-asserted by the writer itself; the batch's own
2211
- // outcome adds the NEW cut set below.
2212
- []);
2213
- runTestTargetsBounded({
2214
- targets,
2215
- concurrency: TEST_RUNNER_BATCH_CONCURRENCY,
2216
- budgetMs: TEST_RUNNER_BATCH_BUDGET_MS,
2217
- // Both bounds, per AGENTS.md: a wall budget AND the ambient
2218
- // abort signal the rest of the spawn layer already honours.
2219
- signal: getAmbientAbortSignal(),
2220
- run: (t, batchSignal) => testRunnerClient.runTestFileAsync(t.testFile, cwd, {
2221
- runner: t.runner,
2222
- config: t.config,
2223
- turnIndex: firedAtTurn,
2224
- // #2522 R2 F1: the BATCH's signal, so spending the 20s wall
2225
- // budget kills this spawn rather than letting it run out its
2226
- // own 60s timeout behind a batch that has already returned.
2227
- signal: batchSignal,
2228
- }),
2229
- })
2230
- .then(({ results, deferred, stopReason }) => {
2231
- const settledResults = results;
2232
- const verdicts = settledResults.flatMap((result) => {
2233
- if (result.status === "rejected")
2234
- return [];
2235
- const target = targets.find((candidate) => candidate.testFile === result.value.file);
2236
- return target
2237
- ? [
2238
- {
2239
- file: result.value.file,
2240
- sourceFile: target.sourceFile,
2241
- fileSeq: target.fileSeqAtRun === undefined
2242
- ? {
2243
- state: "unknown",
2244
- reason: "sequence-unavailable",
2245
- }
2246
- : {
2247
- state: "known",
2248
- value: target.fileSeqAtRun,
2249
- },
2250
- },
2251
- ]
2252
- : [];
2253
- });
2254
- const deferredTargets = deferred.map((t) => ({
2255
- testFile: t.testFile,
2256
- sourceFile: t.sourceFile,
2257
- runner: t.runner,
2258
- // One more cut batch for this target. Read back by the
2259
- // selection loop above, which retires it at
2260
- // TEST_RUNNER_MAX_DEFERRALS rather than carrying it forever.
2261
- attempts: (t.deferralAttempts ?? 0) + 1,
2262
- // #2522 R3 F5: stamped with the session that cut it, so the
2263
- // next session re-arms this target instead of inheriting an
2264
- // attempt count measured under a load it never saw.
2265
- sessionId: firedSessionId,
2266
- }));
2267
- if (deferred.length > 0) {
2268
- // #2522 review round 2, F4: `incrementDegradationCount`, not
2269
- // `recordDegradationOnce`. At a 20s budget this is a
2270
- // RECURRING event, and a once-per-subject entry reports the
2271
- // tenth cut batch of a session exactly like the first.
2272
- incrementDegradationCount({
2273
- kind: "test-runner-batch-capped",
2274
- subject: `${cwd}:${stopReason ?? "incomplete"}`,
2275
- reason: `test batch stopped early (${stopReason ?? "incomplete"}); ${deferred.length} target(s) killed or never dispatched, deferred to the next turn`,
2276
- });
2277
- dbg(`turn_end: test batch stopped early (${stopReason ?? "incomplete"}), ${deferred.length} target(s) deferred to the next turn`);
2278
- }
2279
- const deferralNote = deferred.length > 0
2280
- ? `${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
2281
- .map((t) => path.relative(cwd, t.testFile))
2282
- .join(", ")}`
2283
- : "";
2284
- /**
2285
- * A newer batch has already published for this project — this
2286
- * one's results are stale by construction and must not overwrite
2287
- * it. Was inlined three times; extracted once round 2 added a
2288
- * fourth call site.
2289
- */
2290
- const supersededByNewerGeneration = (label) => {
2291
- const current = cacheManager.readCache("test-runner-findings", cwd)?.data;
2292
- const currentGeneration = current?.testRunGeneration;
2293
- if (currentGeneration !== undefined &&
2294
- currentGeneration > testRunGeneration) {
2295
- dbg(`turn_end: ${label}test generation ${testRunGeneration} superseded by ${currentGeneration}`);
2296
- // #2522 review round 3, F3: returning here USED to drop this
2297
- // batch's cut targets entirely. The newer batch's own pre-run
2298
- // write already cleared them, and it never saw this set — so
2299
- // with two overlapping batches the targets the older one was
2300
- // cut on were never deferred, never re-selected, and the suite
2301
- // they belong to simply never ran again. Hand them over
2302
- // instead. Round 4, I2: the merge is `writeTestFindings`'s own
2303
- // rule now, not a second copy of it here — every branch below
2304
- // merges the same way, so a NEWER batch publishing after this
2305
- // one no longer flattens what this hand-over just recorded.
2306
- if (deferredTargets.length > 0) {
2307
- writeTestFindings({ ...(current ?? { content: "" }) }, deferredTargets);
2308
- dbg(`turn_end: ${label}carried ${deferredTargets.length} cut target(s) into the newer generation's deferral set`);
2309
- }
2310
- return true;
2311
- }
2312
- return false;
2313
- };
2314
- const publishedAgainst = snapshotAdvisoryProvenance({
2315
- cwd,
2316
- runtime,
2317
- generation: testRunGeneration,
2318
- files: provenanceFiles,
2319
- });
2320
- const superseded = launchedFrom.revision.sessionId !==
2321
- publishedAgainst.revision.sessionId ||
2322
- launchedFrom.revision.projectSeq !==
2323
- publishedAgainst.revision.projectSeq ||
2324
- launchedFrom.revision.turnIndex !==
2325
- publishedAgainst.revision.turnIndex ||
2326
- launchedFrom.files.some((file, index) => publishedAgainst.files[index]?.sha256 !== file.sha256 ||
2327
- publishedAgainst.files[index]?.path !== file.path);
2328
- // #628: the turn advancing while tests ran no longer means the
2329
- // results are thrown away — a late result is still real
2330
- // information about what's currently broken. It's tagged `stale`
2331
- // so a downstream consumer can distinguish it from a result that
2332
- // arrived in time, but it's cached either way.
2333
- const stale = runtime.turnIndex !== firedAtTurn;
2334
- const failures = [];
2335
- const resultValues = [];
2336
- let rejectedCount = 0;
2337
- // #2522: whether ANY reported item is a genuine failing test
2338
- // (`failed > 0`) as opposed to the runner itself never
2339
- // completing (timeout, missing provider/binary, a rejected
2340
- // promise). Drives the delivery framing below — a batch made
2341
- // up ENTIRELY of runner errors is not something the agent
2342
- // introduced, so it must not read as "fix before continuing".
2343
- let hasRealFailure = false;
2344
- for (const r of results) {
2345
- if (r.status === "rejected") {
2346
- rejectedCount++;
2347
- emitBounded("test_runner_delivery", `${cwd}:generation:${testRunGeneration}:rejected`, {
2348
- filePath: cwd,
2349
- durationMs: 0,
2350
- metadata: {
2351
- outcome: "runner-promise-rejected",
2352
- sessionId: firedSessionId,
2353
- generation: testRunGeneration,
2354
- targetCount: targets.length,
2355
- droppedDetailCount: 0,
2356
- reason: String(r.reason).slice(0, 500),
2357
- },
2358
- }, {
2359
- ledgerKind: "test-runner-delivery",
2360
- reason: "test runner promise rejected",
2361
- capPerTurn: { limit: 8, turnIndex: firedAtTurn },
2362
- });
2363
- dbg(`turn_end: test run rejected — ${r.reason}`);
2364
- continue;
2365
- }
2366
- resultValues.push(r.value);
2367
- const { file, runner, passed, failed, duration, error } = r.value;
2368
- if (failed > 0)
2369
- hasRealFailure = true;
2370
- const shortFile = path.basename(file);
2371
- // #1479: `(0ms)` used to be printed for a run nobody
2372
- // timed — a payload with no suite timestamps, an
2373
- // unrecognised summary line, or an empty result — and
2374
- // that is the same string a genuinely sub-millisecond
2375
- // run produces. A reader could not tell "measured 0"
2376
- // from "not measured", which is the confusion #1452 was
2377
- // reported for. `duration` is now absent when it was
2378
- // never measured, and this line says which one it has.
2379
- //
2380
- // #1480: the test is `formatRunDurationMs`, not an
2381
- // inline comparison. The "absent = unmeasured" contract
2382
- // was being re-derived at every site that read a
2383
- // duration, and a site that gets it slightly wrong —
2384
- // treating a measured `0` as absent — puts the bug back
2385
- // without touching this comment.
2386
- const elapsed = formatRunDurationMs(duration);
2387
- // Lifted out of the template below for the same reason
2388
- // `elapsed` is: the pair read as a nested ternary, which
2389
- // this line only got flagged for because #1479 touched it.
2390
- const verdict = failed > 0 ? "FAIL" : "PASS";
2391
- // #2532 review S1: folded onto `isRunnerErrorResult` instead of
2392
- // re-deriving `error && passed === 0 && failed === 0` here — that
2393
- // local spelling missed a runner error reported alongside partial
2394
- // passes (pytest `Interrupted` after some tests already passed),
2395
- // which read as a clean "PASS Np/0f" line with the error silently
2396
- // dropped. A partial run says so explicitly rather than reading
2397
- // as a clean pass.
2398
- const summary = isRunnerErrorResult(r.value)
2399
- ? passed > 0
2400
- ? `error: ${error} (${passed} passed before)`
2401
- : `error: ${error}`
2402
- : `${verdict} ${passed}p/${failed}f (${elapsed})`;
2403
- dbg(`turn_end: ${stale ? "[stale] " : ""}test ${runner} ${shortFile} → ${summary}`);
2404
- // #1524: also fires on `error` alone, not just `failed > 0`.
2405
- // A runner error (the suite never started, or was
2406
- // interrupted before finishing — spawn/config/timeout
2407
- // failure) can arrive with `failed === 0` even when tests
2408
- // DID pass before it (#2532 review S2 — not "by
2409
- // construction": `parsePytestOutput` sets `error` from the
2410
- // exit code independently of the parsed counts, so
2411
- // `isRunnerErrorResult` is `failed === 0 && !!error`, not an
2412
- // invariant elsewhere). Gating on `failed > 0` alone dropped
2413
- // it silently: the agent got no context at all, and the
2414
- // empty `failures` array below sent this result down the
2415
- // "all tests passed" branch, clearing any prior real
2416
- // test-failure git-guard blocker. `formatResult` already
2417
- // renders the error-only case as "Could not run tests: ...".
2418
- if (failed > 0 || error) {
2419
- // #2028: "Test file not found" is an expected skip
2420
- // (conventional test path without an actual file),
2421
- // not an actionable failure. Don't surface it.
2422
- if (error &&
2423
- String(r.value?.error ?? "").includes("Test file not found")) {
2424
- continue;
2425
- }
2426
- const formatted = testRunnerClient.formatResult(r.value);
2427
- if (formatted)
2428
- failures.push(formatted);
2429
- }
2430
- }
2431
- if (rejectedCount > 0) {
2432
- failures.push(`Test runner rejected ${rejectedCount} promise(s) before producing a structured result.`);
2433
- }
2434
- if (failures.length > 0) {
2435
- if (supersededByNewerGeneration(""))
2436
- return;
2437
- const content = [
2438
- stale
2439
- ? "[from a prior turn — the edit that triggered this run had already been superseded by the time results came back]"
2440
- : "",
2441
- failures.join("\n\n"),
2442
- // A batch can BOTH find a real failure and be cut short.
2443
- // The agent needs to see the failure AND that the run was
2444
- // incomplete, or it reads a partial list as the full one.
2445
- deferralNote,
2446
- ]
2447
- .filter(Boolean)
2448
- .join("\n\n");
2449
- // #2522: nothing in this batch is a genuine failing test —
2450
- // every entry is a runner error (timeout, missing
2451
- // provider/binary, a rejected promise). `peekTestFindings`
2452
- // reads this to deliver advisory framing instead of
2453
- // "fix before continuing", since the agent introduced
2454
- // nothing here to fix.
2455
- const runnerErrorOnly = !hasRealFailure;
2456
- writeTestFindings({
2457
- content,
2458
- stale,
2459
- results: resultValues,
2460
- verdicts,
2461
- testRunGeneration,
2462
- launchedFrom,
2463
- publishedAgainst,
2464
- provenance: publishedAgainst,
2465
- superseded,
2466
- runnerErrorOnly,
2467
- }, deferredTargets);
2468
- try {
2469
- deps.onTestRunnerComplete?.({
2470
- cwd,
2471
- sessionId: firedSessionId,
2472
- generation: testRunGeneration,
2473
- targetCount: targets.length,
2474
- hasFindings: true,
2475
- });
2476
- }
2477
- catch (deliveryErr) {
2478
- dbg(`turn_end: test delivery staging failed — ${deliveryErr}`);
2479
- }
2480
- if (getFlag("lens-guard") &&
2481
- firedSessionId === runtime.telemetrySessionId) {
2482
- // #1524: `&& !value.error` — a runner-error result has
2483
- // `failed === 0` (the suite never ran, so nothing could
2484
- // fail), but it is not a pass. Without the filter it
2485
- // would clear a prior real test-failure git-guard
2486
- // blocker on the strength of a suite that never
2487
- // started. And the call itself is skipped when this
2488
- // list is empty rather than passed as `[]`:
2489
- // `clearGitGuardTestFailure`'s own empty-array
2490
- // fallback treats "no files named" as "clear every
2491
- // blocked file", so an all-error batch (one go file,
2492
- // runner-error, zero clean files) would otherwise
2493
- // clear every blocker through that fallback instead
2494
- // of clearing none.
2495
- const cleanFiles = resultValues
2496
- .filter((value) => value.failed === 0 && !value.error)
2497
- .map((value) => value.file);
2498
- if (cleanFiles.length > 0) {
2499
- clearGitGuardTestFailure(cacheManager, cwd, runtime, cleanFiles);
2500
- }
2501
- // #2532: `runnerErrorOnly` (computed above for the turn-end
2502
- // delivery framing) is true exactly when nothing in this
2503
- // batch is a genuine failing test — every entry is a
2504
- // runner error the agent did not introduce. Without this
2505
- // gate, an all-runner-error batch still called
2506
- // `mergeGitGuardTestFailure` with an EMPTY failed-files
2507
- // list, which unconditionally sets `hasBlockers: true`:
2508
- // the identical event the turn-end message reports as
2509
- // advisory read as "COMMIT BLOCKED" under --lens-guard.
2510
- if (!runnerErrorOnly) {
2511
- mergeGitGuardTestFailure(cacheManager, cwd, runtime, content, resultValues
2512
- .filter((value) => value.failed > 0)
2513
- .map((value) => value.file));
2514
- }
2515
- else {
2516
- // #2532 review T2: the skip above is otherwise pull-only —
2517
- // nothing records that a batch with real content
2518
- // (`failures.length > 0`) was deliberately kept OFF the
2519
- // `--lens-guard` blocker because every entry was a runner
2520
- // error. Same phase/ledger as the rejected-promise event
2521
- // above, so both `--lens-guard` demotions land in one
2522
- // queryable place. Reached only inside the enclosing
2523
- // `getFlag("lens-guard") && firedSessionId === …` check —
2524
- // no point recording a demotion the flag can't act on.
2525
- emitBounded("test_runner_delivery", `${cwd}:generation:${testRunGeneration}:runner-error-only`, {
2526
- filePath: cwd,
2527
- durationMs: 0,
2528
- metadata: {
2529
- outcome: "runner-error-only-not-blocking",
2530
- sessionId: firedSessionId,
2531
- generation: testRunGeneration,
2532
- targetCount: targets.length,
2533
- droppedDetailCount: 0,
2534
- },
2535
- }, {
2536
- ledgerKind: "test-runner-delivery",
2537
- reason: "runner-error-only batch kept off the --lens-guard blocker",
2538
- capPerTurn: { limit: 8, turnIndex: firedAtTurn },
2539
- });
2540
- }
2541
- }
2542
- dbg(`turn_end: ${failures.length} test failure(s) cached for pull diagnostics and post-agent delivery${stale ? " (stale — turn advanced while tests ran)" : ""}`);
2543
- }
2544
- else if (deferred.length > 0) {
2545
- // #2522 review round 2, F1: a PARTIAL batch is not a clean
2546
- // batch. Pre-round-2 this fell through to the branch below —
2547
- // `content: ""`, "all tests passed", and a `--lens-guard`
2548
- // clear — on the strength of a run that never finished. The
2549
- // record names the deferral instead; the commit gate is left
2550
- // exactly as it was, because nothing here proves anything
2551
- // passed that was not already proven.
2552
- if (supersededByNewerGeneration("deferred "))
2553
- return;
2554
- writeTestFindings({
2555
- content: deferralNote,
2556
- stale,
2557
- results: resultValues,
2558
- verdicts,
2559
- testRunGeneration,
2560
- launchedFrom,
2561
- publishedAgainst,
2562
- provenance: publishedAgainst,
2563
- superseded,
2564
- // Advisory framing: an unfinished batch is not a
2565
- // failure the agent introduced, exactly like a runner
2566
- // error. See `TestRunnerFindingsCache.runnerErrorOnly`.
2567
- runnerErrorOnly: true,
2568
- }, deferredTargets);
2569
- try {
2570
- deps.onTestRunnerComplete?.({
2571
- cwd,
2572
- sessionId: firedSessionId,
2573
- generation: testRunGeneration,
2574
- targetCount: targets.length,
2575
- hasFindings: true,
2576
- });
2577
- }
2578
- catch (deliveryErr) {
2579
- dbg(`turn_end: test delivery staging failed — ${deliveryErr}`);
2580
- }
2581
- dbg(`turn_end: partial test batch — ${results.length} target(s) settled, ${deferred.length} deferred to the next turn; NOT recorded as a clean run`);
2582
- }
2583
- else if (results.length > 0) {
2584
- if (supersededByNewerGeneration("clean "))
2585
- return;
2586
- writeTestFindings({
2587
- ...(priorTestCache ?? { content: "" }),
2588
- content: "",
2589
- stale: false,
2590
- results: resultValues,
2591
- testRunGeneration,
2592
- launchedFrom,
2593
- publishedAgainst,
2594
- provenance: publishedAgainst,
2595
- superseded,
2596
- },
2597
- // Nothing was cut. This turn's own settled entries drop out;
2598
- // I2 keeps every row this batch does not own — including a
2599
- // concurrent older batch's hand-over.
2600
- []);
2601
- try {
2602
- deps.onTestRunnerComplete?.({
2603
- cwd,
2604
- sessionId: firedSessionId,
2605
- generation: testRunGeneration,
2606
- targetCount: targets.length,
2607
- hasFindings: false,
2608
- });
2609
- }
2610
- catch (deliveryErr) {
2611
- dbg(`turn_end: test delivery staging failed — ${deliveryErr}`);
2612
- }
2613
- if (getFlag("lens-guard") &&
2614
- firedSessionId === runtime.telemetrySessionId) {
2615
- clearGitGuardTestFailure(cacheManager, cwd, runtime, resultValues.map((value) => value.file));
2616
- }
2617
- dbg(`turn_end: all tests passed${stale ? " (stale — turn advanced while tests ran)" : ""}`);
2618
- }
2619
- })
2620
- .catch(() => { });
2621
- }
2622
- else if (carriedDeferred.length > 0) {
2623
- // Nothing fired, so no batch outcome will write the list back, but this
2624
- // turn still settled part of it (targets retired at the cap, or dropped
2625
- // because their file is gone). Persist that here rather than re-reading
2626
- // a set of dead paths on every future turn; `writeTestFindings` carries
2627
- // this turn's retirements alongside, which is what the deferral-cap
2628
- // branch above depends on when it retires the LAST carried target and
2629
- // fires nothing. Rows this turn only HELD (over the cap) or does not
2630
- // own (another session's) are re-asserted by the writer itself.
2631
- writeTestFindings({ ...(priorTestCache ?? { content: "" }) }, []);
2632
- }
2633
- }
2634
- if (runtime.errorDebtBaseline && files.length > 0) {
2635
- dbg("turn_end: marking error debt check for next session");
2636
- cacheManager.writeCache("errorDebt", {
2637
- pendingCheck: true,
2638
- baselineTestsPassed: runtime.errorDebtBaseline.testsPassed,
2639
- }, cwd);
2640
- }
2641
- // Session summaries are intentionally suppressed at turn_end to avoid
2642
- // distracting the agent with non-blocking telemetry.
2643
- // Call-graph impact analysis — surface WillBreak/MayBreak callers for modified
2644
- // symbols. MUST run BEFORE the writeProjectDiagnosticsDeltaReport serialization
2645
- // below: it is a delta contributor (like knip above), pushing into
2646
- // projectDiagnosticsDelta / projectDiagnosticsSources. If it ran after the
2647
- // single write, a call-graph-only turn would persist nothing and a mixed turn
2648
- // would drop the call-graph entries — so lens_diagnostics (which only reads the
2649
- // persisted report) would never surface the findings (#179/#533).
2650
- if (runtime.callGraph && files.length > 0) {
2651
- const coverage = runtime.callGraph.coverage;
2652
- if (!coverage || coverage.complete !== true) {
2653
- // An incomplete graph can still contain useful edges, but emitting them
2654
- // as ordinary impact findings would turn unsupported/partial extraction
2655
- // into an authoritative-looking clean result for the rest of the file.
2656
- // Keep the limitation visible and require a complete graph for this
2657
- // user-facing impact surface (#1070).
2658
- // @delivery-surface: runtime-turn:call-graph-advisory
2659
- advisoryParts.push("Call-graph impact was not emitted because call-graph extraction coverage is incomplete; " +
2660
- "the affected files may have unreported callers.");
2661
- }
2662
- else {
2663
- try {
2664
- const { impact, formatImpact, parseSymbolKey } = await import("./call-graph.js");
2665
- const { callGraphImpactToProjectDiagnostics } = await import("./project-diagnostics/runner-adapters/call-graph-impact.js");
2666
- const impactLines = [];
2667
- const impactFindings = [];
2668
- for (const filePath of files.slice(0, 5)) {
2669
- // Turn-state files may be cwd-relative while graph keys are absolute,
2670
- // and persisted graphs can contain either slash style/casing. Compare
2671
- // through the shared normalized path seam; keep the original filePath
2672
- // only for display and diagnostics.
2673
- const changedFileKey = normalizeMapKey(resolveRunnerPath(cwd, filePath));
2674
- const fileCallerKeys = [...runtime.callGraph.callers.keys()].filter((k) => {
2675
- const graphFilePath = parseSymbolKey(k).filePath;
2676
- return (normalizeMapKey(resolveRunnerPath(cwd, graphFilePath)) ===
2677
- changedFileKey);
2678
- });
2679
- for (const calleeKey of fileCallerKeys.slice(0, 3)) {
2680
- // #1080: drop KNOWN test-role callers BEFORE both the human advisory
2681
- // (formatImpact below) and the persisted delta (impactFindings →
2682
- // callGraphImpactToProjectDiagnostics) — the advisory is rendered
2683
- // first, so the filter must reach the shared `results` set that feeds
2684
- // both. A test caller supplied by an old/fixture/expanded graph must
2685
- // appear in neither surface. Fail-open: an unparseable/unclassifiable
2686
- // key is retained (the adapter re-applies the same predicate).
2687
- const results = impact(runtime.callGraph, calleeKey).filter((r) => {
2688
- const callerFile = parseSymbolKey(r.symbolKey).filePath;
2689
- return (!callerFile ||
2690
- !isTestRoleCollateral(resolveRunnerPath(cwd, callerFile)));
2691
- });
2692
- if (results.length > 0) {
2693
- impactFindings.push({ calleeKey, results });
2694
- const summary = formatImpact(results, cwd);
2695
- if (summary)
2696
- impactLines.push(` ${parseSymbolKey(calleeKey).symbolName ?? calleeKey}: ${summary}`);
2697
- }
2698
- }
2699
- }
2700
- if (impactLines.length > 0) {
2701
- // @delivery-surface: runtime-turn:call-graph-advisory
2702
- advisoryParts.push(`📊 Call-graph impact (changed symbols have callers):\n${impactLines.join("\n")}`);
2703
- }
2704
- if (impactFindings.length > 0) {
2705
- const impactDiagnostics = callGraphImpactToProjectDiagnostics(cwd, impactFindings);
2706
- if (impactDiagnostics.length > 0) {
2707
- projectDiagnosticsDelta.push(...impactDiagnostics);
2708
- projectDiagnosticsSources.add("call-graph");
2709
- }
2710
- }
2711
- // Non-fatal — call graph is best-effort
2712
- }
2713
- catch {
2714
- // Non-fatal — call graph is best-effort
2715
- }
2716
- }
2717
- }
2718
- if (projectDiagnosticsDelta.length > 0) {
2719
- writeProjectDiagnosticsDeltaReport(cwd, {
2720
- version: PROJECT_DIAGNOSTICS_CACHE_VERSION,
2721
- cwd,
2722
- generatedAt: new Date().toISOString(),
2723
- sessionId: runtime.telemetrySessionId,
2724
- turnIndex: runtime.turnIndex,
2725
- projectSeqStart: runtime.turnStartProjectSeq,
2726
- projectSeqEnd: runtime.projectSeq,
2727
- diagnostics: projectDiagnosticsDelta,
2728
- sources: [...projectDiagnosticsSources].sort((a, b) => a.localeCompare(b)),
2729
- });
2730
- }
2731
- const t4 = Date.now();
2732
- const modifiedRangesByFile = new Map(Object.entries(turnState.files).map(([file, state]) => [
2733
- normalizeMapKey(resolveRunnerPath(cwd, file)),
2734
- state.modifiedRanges,
2735
- ]));
2736
- const getFileSeq = runtime.getFileSeq;
2737
- const fileSeqByPath = new Map();
2738
- if (getFileSeq) {
2739
- for (const file of files) {
2740
- const filePath = normalizeMapKey(resolveRunnerPath(cwd, file));
2741
- fileSeqByPath.set(filePath, getFileSeq.call(runtime, filePath));
2742
- }
2743
- }
2744
- let reverify;
2745
- if (getFlag("lens-actionable-warnings")) {
2746
- // #3170: re-verify carried deferred findings before the advisory
2747
- // assembles — a finding whose file is unchanged re-serves from the
2748
- // persisted report without ever being re-observed; root-cause-fixed-
2749
- // elsewhere findings converge here instead of repeating. Bounded in the
2750
- // module (≤4 files, wall budget, abort signal). #3176 F1: the
2751
- // replacement entries fold into THIS turn's single in-band publish
2752
- // below — a separate replacement publish spends the carry marker and
2753
- // this publish then drops the entries at the scope guard (the blocker
2754
- // the review caught).
2755
- const persistedReport = cacheManager.readCache("actionable-warnings", cwd, Number.MAX_SAFE_INTEGER)?.data;
2756
- if (persistedReport?.files?.some((entry) => entry.origin === "deferred")) {
2757
- const reverifyLspService = getLSPService();
2758
- if (reverifyLspService) {
2759
- // #2523: the hook-path await is bound-wrapped with the hook's own
2760
- // budget and signal; the pass's internal deadline (3s) is the
2761
- // tighter of the two.
2762
- reverify = await bounded(runPersistentReverify({
2763
- report: persistedReport,
2764
- cwd,
2765
- lspService: reverifyLspService,
2766
- signal: getAmbientAbortSignal(),
2767
- }), {
2768
- ms: HOOK_WALL_BUDGET_MS.turn_end,
2769
- signal: getAmbientAbortSignal(),
2770
- hook: "turn_end",
2771
- label: "persistent_reverify",
2772
- });
2773
- }
2774
- }
2775
- try {
2776
- const report = await buildActionableWarningsReport({
2777
- cwd,
2778
- sessionId: runtime.telemetrySessionId,
2779
- turnIndex: runtime.turnIndex,
2780
- files,
2781
- modifiedRangesByFile,
2782
- // Suppress the ast-grep secret advisory at any location already
2783
- // surfaced in the unified secrets blocker above (#131 Mode 3) — the
2784
- // secret is reported once, not twice.
2785
- dispatchWarnings: runtime
2786
- .peekActionableWarnings()
2787
- .filter((w) => !(isSecretWarning(w) &&
2788
- typeof w.line === "number" &&
2789
- secretBlockedLocations.has(secretLocationKey(w.filePath, w.line)))),
2790
- includeLspCodeActions: !!getFlag("lens-actionable-warning-actions"),
2791
- projectSeqStart: runtime.turnStartProjectSeq,
2792
- projectSeqEnd: runtime.projectSeq,
2793
- fileSeqByPath,
2794
- deltaOnly: !getFlag("lens-actionable-warning-all"),
2795
- dbg,
2796
- // #2504: this call is AWAITED on the turn_end hook, so its cost is
2797
- // terminal-blocking time. The file cap and wall budget are its
2798
- // bounds; the abort signal is the second one AGENTS.md requires.
2799
- signal: getAmbientAbortSignal(),
2800
- // A cold-cache turn hands the fresh-pull loop back here, off the
2801
- // hook — it lands in the same cache the in-band report goes to.
2802
- onDeferredReport: (deferred) => {
2803
- try {
2804
- // #2504 review round 2 (F2): GUARDED. This callback fires up
2805
- // to a minute after the turn that armed it, and the report
2806
- // it carries is stamped with THAT turn's
2807
- // turnIndex/projectSeq. Writing it unconditionally
2808
- // overwrote a newer report, which `agent_end` then rejected
2809
- // as `project_seq_mismatch` (silently skipping the autofix
2810
- // pass) and `lens_diagnostics` re-served as a stale delta.
2811
- // #2504 review round 4 (F1): guarded PER FILE, not per
2812
- // report. The round-3 shape (publish, or discard whole
2813
- // when something newer is persisted) composed with
2814
- // incumbent-wins into "publish nothing" — every turn_end
2815
- // with modified files persists an in-band report with a
2816
- // strictly increasing turnIndex, and the decline fires
2817
- // exactly when such a turn runs while a loop is in
2818
- // flight, so a decline always implied a supersede. The
2819
- // merge upserts the entries whose file has not moved and
2820
- // drops only those that have.
2821
- writeDeferredActionableWarningsReport({
2822
- cacheManager,
2823
- cwd,
2824
- report: deferred,
2825
- // The LIVE per-file sequence at WRITE time is the
2826
- // baseline the merge judges each entry against. The
2827
- // runtime is the only thing that knows it: a file
2828
- // edited into cleanliness by a later turn is absent
2829
- // from the persisted report entirely.
2830
- getFileSeq: getFileSeq
2831
- ? (filePath) => getFileSeq.call(runtime, filePath)
2832
- : undefined,
2833
- dbg,
2834
- });
2835
- }
2836
- catch (deferErr) {
2837
- dbg(`turn_end: deferred actionable-warnings write failed — ${deferErr}`);
2838
- }
2839
- },
2840
- });
2841
- // #2504 review round 5 (F1): THE publish choke point. This was a
2842
- // blind `writeActionableWarningsReport` while the deferred callback
2843
- // above read-modify-wrote the SAME cache key. The deferred merge can
2844
- // land anywhere inside this handleTurnEnd -- the cascade settle,
2845
- // knip, madge, the test batch, the in-band LSP enrichment are all
2846
- // awaited between the moment it is armed and the moment we get
2847
- // here -- and the blind write erased it 607 ms later in the
2848
- // reviewer's trace. The publisher now always reads what is persisted
2849
- // and merges per file, so the two writers cannot race. It carries
2850
- // forward only entries a DEFERRAL produced, and only while their
2851
- // file has not moved, so a `turn_delta` report does not accumulate
2852
- // every prior turn's findings.
2853
- // #3176 F1: the re-verify replacements fold into THIS report — one
2854
- // in-band publish total, so the carry marker is spent exactly once and
2855
- // the merged report cannot drop the entries behind the publish (the
2856
- // blocker the review caught).
2857
- if (reverify?.replacementFiles.length) {
2858
- const replacementByPath = new Map(reverify.replacementFiles.map((file) => [
2859
- normalizeMapKey(file.filePath),
2860
- file,
2861
- ]));
2862
- const files = (report.files ?? []).map((file) => replacementByPath.get(normalizeMapKey(file.filePath)) ?? file);
2863
- for (const replacement of reverify.replacementFiles) {
2864
- if (!files.some((file) => normalizeMapKey(file.filePath) ===
2865
- normalizeMapKey(replacement.filePath))) {
2866
- files.push(replacement);
2867
- }
2868
- }
2869
- report.files = files;
2870
- }
2871
- const publishResult = publishActionableWarningsReport(cacheManager, cwd, report, {
2872
- origin: "in-band",
2873
- getFileSeq: getFileSeq
2874
- ? (filePath) => getFileSeq.call(runtime, filePath)
2875
- : undefined,
2876
- dbg,
2877
- });
2878
- // #2504 review round 6 (a): the advisory must read the MERGED report,
2879
- // not the pre-merge `report` this turn assembled -- a rescued deferred
2880
- // entry lives only on `publishResult.report`, and formatting the
2881
- // pre-merge report silently dropped it from the turn_end advisory
2882
- // even though it was correctly persisted to cache.
2883
- // #2521: `cwd` is what resolves the store location the advisory
2884
- // names -- it is the SAME cwd `publishActionableWarningsReport`
2885
- // just wrote through, so the two cannot disagree.
2886
- const advisory = formatActionableWarningsAdvisory(publishResult.report, cwd, host);
2887
- // @delivery-surface: runtime-turn:actionable-warnings-advisory
2888
- if (advisory)
2889
- advisoryParts.push(advisory);
2890
- logActionableWarningsEvent({
2891
- event: advisory ? "advisory_injected" : "advisory_skipped",
2892
- sessionId: runtime.telemetrySessionId,
2893
- metadata: {
2894
- turnIndex: runtime.turnIndex,
2895
- // #2504 review round 7 (F2): the MERGED report, matching the
2896
- // advisory text above it (round 6, a) -- a rescued deferred
2897
- // entry lives only on `publishResult.report`, and the pre-merge
2898
- // `report` this turn assembled undercounts it. This is the value
2899
- // scripts/analyze-pi-lens-logs.mjs sums, so an undercount here is
2900
- // a silent miscount there too.
2901
- unsuppressed: publishResult.report.summary.unsuppressed,
2902
- },
2903
- });
2904
- logLatency({
2905
- type: "phase",
2906
- toolName: "turn_end",
2907
- filePath: cwd,
2908
- phase: "actionable_warnings_report",
2909
- durationMs: Date.now() - t4,
2910
- metadata: report.summary,
2911
- });
2912
- }
2913
- catch (err) {
2914
- dbg(`turn_end: actionable warning report failed: ${err}`);
2915
- logLatency({
2916
- type: "phase",
2917
- toolName: "turn_end",
2918
- filePath: cwd,
2919
- phase: "actionable_warnings_report",
2920
- durationMs: Date.now() - t4,
2921
- metadata: {
2922
- failed: true,
2923
- error: err instanceof Error ? err.message : String(err),
2924
- },
2925
- });
2926
- }
2927
- }
2928
- const t5 = Date.now();
2929
- try {
2930
- const qualityReport = buildCodeQualityWarningsReport({
2931
- cwd,
2932
- sessionId: runtime.telemetrySessionId,
2933
- turnIndex: runtime.turnIndex,
2934
- warnings: runtime.peekCodeQualityWarnings(),
2935
- modifiedRangesByFile,
2936
- projectSeqStart: runtime.turnStartProjectSeq,
2937
- projectSeqEnd: runtime.projectSeq,
2938
- fileSeqByPath,
2939
- });
2940
- writeCodeQualityWarningsReport(cacheManager, cwd, qualityReport);
2941
- appendCodeQualityWarningsHistory(cwd, qualityReport);
2942
- const advisory = formatCodeQualityWarningsAdvisory(qualityReport, cwd, host);
2943
- // @delivery-surface: runtime-turn:code-quality-warnings-advisory
2944
- if (advisory)
2945
- advisoryParts.push(advisory);
2946
- logLatency({
2947
- type: "phase",
2948
- toolName: "turn_end",
2949
- filePath: cwd,
2950
- phase: "code_quality_warnings_report",
2951
- durationMs: Date.now() - t5,
2952
- metadata: qualityReport.summary,
2953
- });
2954
- }
2955
- catch (err) {
2956
- dbg(`turn_end: code quality warning report failed: ${err}`);
2957
- logLatency({
2958
- type: "phase",
2959
- toolName: "turn_end",
2960
- filePath: cwd,
2961
- phase: "code_quality_warnings_report",
2962
- durationMs: Date.now() - t5,
2963
- metadata: {
2964
- failed: true,
2965
- error: err instanceof Error ? err.message : String(err),
2966
- },
2967
- });
2968
- }
2969
- cacheManager.incrementTurnCycle(cwd, currentOwner);
2970
- // Collect-later CLI runners continue off the write path. Their completed
2971
- // diagnostics use the same freshness gate as late auxiliary findings and
2972
- // enter the ordinary turn-end advisory delivery channel.
2973
- const runnerFindingsStart = Date.now();
2974
- // Turn-end delivery is deliberately non-blocking. Collect already-settled
2975
- // results and requeue the rest; the edit path already paid the deferral
2976
- // decision, so another 2s wait would charge every turn while a runner is
2977
- // still in flight (#2122 F5).
2978
- const pendingRunnerFindings = await drainPendingRunnerFindings(0);
2979
- let runnerFindingsDelivered = 0;
2980
- let runnerFindingsStale = 0;
2981
- let runnerFindingsFailed = 0;
2982
- let runnerFindingsDropped = 0;
2983
- const runnerFindingsDeliveredIds = [];
2984
- for (const pending of pendingRunnerFindings) {
2985
- const result = pending.result;
2986
- if (!result)
2987
- continue;
2988
- recordRunner(pending.filePath, pending.runnerId, result.status, result.diagnostics.length, Date.now() - pending.markedAtMs, pending.writeIndex);
2989
- if (result.status === "failed") {
2990
- runnerFindingsFailed += 1;
2991
- const detail = result.failureMessage ? `: ${result.failureMessage}` : "";
2992
- // @delivery-surface: runtime-turn:late-runner-findings
2993
- advisoryParts.push(`❌ Deferred runner ${pending.runnerId} failed (${result.failureKind ?? "unknown"})${detail}`);
2994
- continue;
2995
- }
2996
- const findings = result.diagnostics;
2997
- if (findings.length === 0)
2998
- continue;
2999
- const gate = gateFindingsByPathFreshness({
3000
- store: "late-runner-findings",
3001
- findings,
3002
- cwd,
3003
- scannedAt: pending.markedAtMs,
3004
- citedPath: (finding) => finding.filePath,
3005
- });
3006
- runnerFindingsStale += gate.stale.length;
3007
- if (gate.stale.length > 0) {
3008
- // The runner answered for bytes older than the latest edit. Do not
3009
- // re-arm this completed answer: only a new runner query can restore
3010
- // coverage for the refreshed bytes.
3011
- dropStaleRunnerFindings(pending);
3012
- runnerFindingsDropped += 1;
3013
- }
3014
- if (gate.live.length === 0)
3015
- continue;
3016
- const displayPath = toRunnerDisplayPath(cwd, pending.filePath);
3017
- const lines = gate.live.map((finding) => ` ${displayPath}:${finding.line ?? 1}:${finding.column ?? 1} [${finding.rule ?? finding.id}] ${finding.message}`);
3018
- runnerFindingsDelivered += gate.live.length;
3019
- for (const finding of gate.live) {
3020
- if (runnerFindingsDeliveredIds.length < 50) {
3021
- runnerFindingsDeliveredIds.push(finding.id);
3022
- }
3023
- }
3024
- // @delivery-surface: runtime-turn:late-runner-findings
3025
- advisoryParts.push(`⏱️ Late runner diagnostics (${pending.runnerId} completed after the edit):\n${lines.join("\n")}`);
3026
- }
3027
- logLatency({
3028
- type: "phase",
3029
- toolName: "turn_end",
3030
- filePath: cwd,
3031
- phase: "late_runner_findings",
3032
- durationMs: Date.now() - runnerFindingsStart,
3033
- metadata: {
3034
- pending: pendingRunnerFindings.length,
3035
- delivered: runnerFindingsDelivered,
3036
- stale: runnerFindingsStale,
3037
- failed: runnerFindingsFailed,
3038
- dropped: runnerFindingsDropped,
3039
- deliveredIds: runnerFindingsDeliveredIds,
3040
- },
3041
- });
3042
- // #2001/#2002: collect-later delivery for auxiliary LSP servers whose
3043
- // aux-grace window expired without a publication (opengrep on Windows:
3044
- // ~8s per scan against a 2s grace — the scanner's eventual findings sat
3045
- // in its client cache, agent-invisible). Probe each pending pair through
3046
- // the read-only cache seam (never spawns), freshness-gate the result
3047
- // against when the pair was marked, and deliver survivors as an advisory.
3048
- // A pair whose client is alive but has STILL published nothing re-arms
3049
- // (baseline preserved, TTL anchor advanced) until the re-arm TTL; a dead
3050
- // client drops silently.
3051
- const lateAuxStart = Date.now();
3052
- const drainedPairs = drainPendingAuxiliaryCoverage();
3053
- // #2168: cap evictions retire a pair before any drain can observe it — read
3054
- // and reset that count here so it folds into this turn's reconciliation
3055
- // sum instead of the pair vanishing uncounted.
3056
- const lateAuxCapEvicted = drainPendingAuxCapEvictedCount();
3057
- let lateAuxDelivered = 0;
3058
- let lateAuxStale = 0;
3059
- let lateAuxMissing = 0;
3060
- let lateAuxRearmed = 0;
3061
- let lateAuxClientGone = 0;
3062
- let lateAuxProbeFailed = 0;
3063
- let lateAuxCleanConfirmed = 0;
3064
- let lateAuxExpired = 0;
3065
- let lateAuxCeilingExhausted = 0;
3066
- let lateAuxAnswered = 0;
3067
- let lateAuxNotifyStallDemoted = 0;
3068
- // #3102: dropped by the shared finding-policy stack (inline `pi-lens-ignore`
3069
- // / stored disposition / `.pi-lens.json` rule policy) and, separately, by the
3070
- // auxiliary profile's OWN native suppression inside `retagAuxiliaryDiagnostics`.
3071
- // Both are reported in the one bounded per-turn record below — a drop is
3072
- // never silent (shape 10).
3073
- let lateAuxDispositionSuppressed = 0;
3074
- let lateAuxAuxSuppressed = 0;
3075
- const lateAuxCoverageGapPairs = [];
3076
- let lateAuxCoverageGapDetailCount = 0;
3077
- let lateAuxCoverageGapDropCount = 0;
3078
- const lateAuxStuckPairs = [];
3079
- if (drainedPairs.length > 0) {
3080
- const lateObserverDeadline = Date.now() + HOOK_WALL_BUDGET_MS.turn_end;
3081
- const byFile = new Map();
3082
- for (const pair of drainedPairs) {
3083
- const list = byFile.get(pair.filePath);
3084
- if (list)
3085
- list.push(pair);
3086
- else
3087
- byFile.set(pair.filePath, [pair]);
3088
- }
3089
- try {
3090
- const service = getLSPService();
3091
- for (const [lateAuxPath, pairs] of byFile) {
3092
- let cached;
3093
- try {
3094
- cached = await service.readCachedDiagnosticsForServers(lateAuxPath, new Set(pairs.map((p) => p.serverId)));
3095
- }
3096
- catch {
3097
- // #2027 round-1 P3-2 / #2167 R2-2: a transient probe rejection
3098
- // tells us nothing about the pair's content, so treat it like the
3099
- // "still scanning" branch below — re-arm under the SAME
3100
- // ceiling/TTL bound rather than dropping the coverage outright.
3101
- // `probeFailed` stays an honest per-turn failure count; it is
3102
- // informational (like `stale`/`missing`), not a terminal bucket,
3103
- // since the pair itself still resolves through rearmed/
3104
- // ceilingExhausted/expired below.
3105
- lateAuxProbeFailed += pairs.length;
3106
- for (const pair of pairs) {
3107
- if (!isPendingAuxiliaryPastRearmTtl(pair) &&
3108
- (pair.rearmCount ?? 0) < MAX_LATE_AUX_REARMS) {
3109
- rearmPendingAuxiliaryCoverage(pair);
3110
- lateAuxRearmed += 1;
3111
- }
3112
- else if (isPendingAuxiliaryPastRearmTtl(pair)) {
3113
- lateAuxExpired += 1;
3114
- }
3115
- else {
3116
- lateAuxCeilingExhausted += 1;
3117
- }
3118
- }
3119
- continue;
3120
- }
3121
- const displayLateAuxPath = toRunnerDisplayPath(cwd, lateAuxPath);
3122
- // #3102: the file's CURRENT bytes, for the two content-bound halves
3123
- // of the policy stack (inline `pi-lens-ignore` and the STRICT
3124
- // `false-positive` anchor) and for the auxiliary profile's own
3125
- // native suppression. Read at most ONCE per file per drain, lazily:
3126
- // a turn that drains nothing, finds no live client, re-arms, or
3127
- // confirms clean pays no I/O at all, and a file with several pending
3128
- // servers pays one read for all of them. Measured cost on the one
3129
- // file that does publish: 0.10-0.20 ms typical, 4.0 ms worst case
3130
- // (a 180 KB file, 5 findings, a populated disposition store) against
3131
- // the 3000 ms `HOOK_WALL_BUDGET_MS.turn_end`. A read failure yields
3132
- // `undefined`: the content-free half still applies and nothing is
3133
- // hidden on an I/O error (shape 48).
3134
- let lateAuxContentRead = false;
3135
- let lateAuxContentValue;
3136
- const readLateAuxContent = () => {
3137
- if (!lateAuxContentRead) {
3138
- lateAuxContentRead = true;
3139
- try {
3140
- lateAuxContentValue = fs.readFileSync(lateAuxPath, "utf-8");
3141
- }
3142
- catch {
3143
- lateAuxContentValue = undefined;
3144
- }
3145
- }
3146
- return lateAuxContentValue;
3147
- };
3148
- for (const pair of pairs) {
3149
- const cachedEntry = cached.get(pair.serverId);
3150
- if (cachedEntry === undefined) {
3151
- // No live client for this server any more — best-effort probe,
3152
- // drop the pair silently.
3153
- lateAuxClientGone += 1;
3154
- continue;
3155
- }
3156
- if (cachedEntry.notifyStallDemoted) {
3157
- if (cachedEntry.demotedAt === undefined ||
3158
- pair.markedAtMs > cachedEntry.demotedAt) {
3159
- // A pair marked after teardown belongs to the missing
3160
- // generation, so it follows ordinary clientGone handling.
3161
- lateAuxClientGone += 1;
3162
- continue;
3163
- }
3164
- // #2356: notify-stall teardown is a transient absence while the
3165
- // breaker cools down, but only for a pair marked before teardown.
3166
- lateAuxNotifyStallDemoted += 1;
3167
- const pastTtl = isPendingAuxiliaryPastRearmTtl(pair);
3168
- const atCeiling = (pair.rearmCount ?? 0) >= MAX_LATE_AUX_REARMS;
3169
- if (!pastTtl && !atCeiling) {
3170
- rearmPendingAuxiliaryCoverage(pair);
3171
- lateAuxRearmed += 1;
3172
- if (lateAuxStuckPairs.length < 20)
3173
- lateAuxStuckPairs.push({
3174
- filePath: pair.filePath,
3175
- serverId: pair.serverId,
3176
- });
3177
- }
3178
- else {
3179
- if (pastTtl)
3180
- lateAuxExpired += 1;
3181
- else
3182
- lateAuxCeilingExhausted += 1;
3183
- lateAuxCoverageGapPairs.push({
3184
- filePath: pair.filePath,
3185
- serverId: pair.serverId,
3186
- });
3187
- }
3188
- continue;
3189
- }
3190
- const rawDiags = cachedEntry.diags;
3191
- if (cachedEntry.publishedAt === undefined ||
3192
- cachedEntry.publishedAt <= pair.markedAtMs) {
3193
- // Still scanning (or published nothing yet) — keep waiting
3194
- // so a scan finishing before the NEXT turn end still
3195
- // delivers. Two clocks, deliberately decoupled: the
3196
- // freshness baseline (`markedAtMs`) NEVER moves — it is what
3197
- // the delivery gate stats against — while the re-arm TTL is
3198
- // anchored on `lastRearmedAtMs`, advanced by every successful
3199
- // empty probe: the scanner is demonstrably alive, just slow.
3200
- if (!isPendingAuxiliaryPastRearmTtl(pair) &&
3201
- (pair.rearmCount ?? 0) < MAX_LATE_AUX_REARMS) {
3202
- rearmPendingAuxiliaryCoverage(pair);
3203
- lateAuxRearmed += 1;
3204
- if (lateAuxStuckPairs.length < 20)
3205
- lateAuxStuckPairs.push({
3206
- filePath: pair.filePath,
3207
- serverId: pair.serverId,
3208
- });
3209
- }
3210
- else {
3211
- if (isPendingAuxiliaryPastRearmTtl(pair))
3212
- lateAuxExpired += 1;
3213
- else
3214
- lateAuxCeilingExhausted += 1;
3215
- }
3216
- continue;
3217
- }
3218
- // A demoted auxiliary still answers through this late path. Feed the
3219
- // publication-minus-mark interval into the re-promotion streak. This is
3220
- // delivery latency observed by the drain, not the scanner's total scan
3221
- // latency. Cache priming is
3222
- // below the freshness gate so a changed file cannot resurrect stale data.
3223
- if (typeof service.observeLateAuxiliaryAnswer === "function") {
3224
- await bounded(service.observeLateAuxiliaryAnswer(lateAuxPath, pair.serverId, cachedEntry.publishedAt - pair.markedAtMs), {
3225
- ms: Math.max(1, lateObserverDeadline - Date.now()),
3226
- signal: deps.signal /* late observer */,
3227
- hook: "turn_end",
3228
- label: "observeLateAuxiliaryAnswer",
3229
- });
3230
- }
3231
- if (rawDiags.length === 0) {
3232
- lateAuxCleanConfirmed += 1;
3233
- continue;
3234
- }
3235
- // `convertLspDiagnostics` drops entries with no start line, which
3236
- // would break the 1:1 index alignment `retagAuxiliaryDiagnostics`
3237
- // needs. Partition first so `converted[i]` IS `anchored[i]` —
3238
- // the same pre-partition `applyLspFindingPolicy` does.
3239
- const anchored = rawDiags.filter((d) => d.range?.start?.line !== undefined);
3240
- if (anchored.length === 0) {
3241
- lateAuxMissing += rawDiags.length;
3242
- lateAuxAnswered += 1;
3243
- continue;
3244
- }
3245
- const lateAuxContent = readLateAuxContent();
3246
- const converted = convertLspDiagnostics(anchored, lateAuxPath);
3247
- // #3046/#3047: the auxiliary's REAL tool id (and its own native
3248
- // inline suppression — opengrep's `# nosemgrep`, ast-grep's
3249
- // test-file gate), from the ONE shared derivation every other
3250
- // surface anchors a mark against. These diagnostics come straight
3251
- // off the aux client's cache, so nothing upstream applied it.
3252
- const retained = retagAuxiliaryDiagnostics(converted, anchored, lateAuxContent ?? "", { cwd, fileRole: detectFileRole(lateAuxPath, lateAuxContent) });
3253
- lateAuxAuxSuppressed += converted.length - retained.length;
3254
- if (retained.length === 0) {
3255
- lateAuxAnswered += 1;
3256
- continue;
3257
- }
3258
- // Freshness kernel (#1634 gated surface): stat the cited file
3259
- // against the mark timestamp. Missing → drop (no remediation for
3260
- // a deleted file); mtime drifted past the mark → drop too, NOT
3261
- // demote — unlike the cached-blocker gates these findings were
3262
- // NEVER delivered before, and the edit that drifted the file
3263
- // already re-touched it (a fresh pending pair supersedes this
3264
- // one), so a stale-arm replay would double-report old content.
3265
- // Both drops are COUNTED here and in the latency record below —
3266
- // never silent (shape 10).
3267
- const gate = gateFindingsByPathFreshness({
3268
- store: "late-auxiliary-findings",
3269
- findings: retained,
3270
- cwd,
3271
- scannedAt: pair.markedAtMs,
3272
- citedPath: () => lateAuxPath,
3273
- });
3274
- lateAuxStale += gate.stale.length;
3275
- lateAuxMissing +=
3276
- retained.length - gate.live.length - gate.stale.length;
3277
- if (gate.live.length === 0) {
3278
- if (gate.stale.length > 0) {
3279
- // Stale findings mean the scan predates the last edit. Re-arm
3280
- // with a refreshed baseline and carry the ceiling count.
3281
- if (!isPendingAuxiliaryPastRearmTtl(pair) &&
3282
- (pair.rearmCount ?? 0) < MAX_LATE_AUX_REARMS) {
3283
- rearmPendingAuxiliaryCoverage(pair, Date.now(), true);
3284
- lateAuxRearmed += 1;
3285
- }
3286
- else if (isPendingAuxiliaryPastRearmTtl(pair)) {
3287
- lateAuxExpired += 1;
3288
- }
3289
- else {
3290
- lateAuxCeilingExhausted += 1;
3291
- }
3292
- }
3293
- else {
3294
- lateAuxAnswered += 1;
3295
- }
3296
- continue;
3297
- }
3298
- // #2810 round 4: this drain does NOT write the hash-bound
3299
- // last-known record. The prime it used to call could only fire when
3300
- // a record already existed at the pair's hash — which requires a
3301
- // FULLY covered touch of those exact bytes, the one case where the
3302
- // scanner's findings are already in the record — so it was a no-op
3303
- // in the demoted steady state it was added for, and a #570/#1470
3304
- // hazard everywhere else (an auxiliary-only array replacing the
3305
- // merged one). Late findings reach the agent as the gated advisory
3306
- // below; the turn-end hash-guarded fast path stays cold for a file
3307
- // whose touch was partial, which is exactly what #1470 requires.
3308
- // #3102: the survivors are what the agent READS, so they take the
3309
- // same `clients/dispatch/finding-policy.ts` stack — inline
3310
- // `pi-lens-ignore` → stored dispositions → `.pi-lens.json` rule
3311
- // policy — the per-edit dispatcher, `mode=full` and the
3312
- // `source=lsp` probe lane apply. Without it a finding the agent
3313
- // marked `false-positive` re-reported on every turn that drained
3314
- // a late pair. Runs AFTER the freshness gate, like the #1625
3315
- // govulncheck/secrets filters: the anchor is derived from the
3316
- // post-gate identity, never the raw pre-gate set.
3317
- const { kept: lateAuxKept } = applyFindingPolicy(gate.live, {
3318
- cwd,
3319
- filePath: lateAuxPath,
3320
- content: lateAuxContent ?? "",
3321
- // mtime-cached, so a drain of several files costs one stat.
3322
- policyMap: loadProjectRulePolicyMap(cwd),
3323
- identities: renderedRuleIdentities,
3324
- });
3325
- const lateAuxSuppressedHere = gate.live.length - lateAuxKept.length;
3326
- lateAuxDispositionSuppressed += lateAuxSuppressedHere;
3327
- if (lateAuxKept.length === 0) {
3328
- // Every late finding was suppressed. This is a PUSH surface:
3329
- // silence after a mark is the mark working, not a clean
3330
- // verdict, so the count rides the bounded per-turn record below
3331
- // instead of re-announcing the suppression every single turn.
3332
- lateAuxAnswered += 1;
3333
- continue;
3334
- }
3335
- const lines = lateAuxKept.map((f) => ` ${displayLateAuxPath}:${f.line}:${f.column} [${f.rule}] ${f.message}`);
3336
- lateAuxDelivered += lateAuxKept.length;
3337
- lateAuxAnswered += 1;
3338
- // #1616 suppressed-bucket rule: a delivery that still has
3339
- // something to say states what it dropped, once per delivery.
3340
- const lateAuxSuppressedNote = lateAuxSuppressedHere > 0
3341
- ? `; suppressed by disposition: ${lateAuxSuppressedHere} finding(s)`
3342
- : "";
3343
- // @delivery-surface: runtime-turn:late-auxiliary-findings
3344
- advisoryParts.push(`🕐 Late auxiliary diagnostics (${pair.serverId} answered after its grace window${lateAuxSuppressedNote}):\n${lines.join("\n")}`);
3345
- }
3346
- }
3347
- }
3348
- catch (err) {
3349
- dbg(`turn_end: late-auxiliary probe failed: ${err}`);
3350
- }
3351
- // #2356: a demoted scanner that never gets replaced remains a coverage
3352
- // gap. Re-raise it once when the existing bounded late-pair window closes,
3353
- // preserving the server/file identity in both the ledger and latency row.
3354
- for (const pair of lateAuxCoverageGapPairs) {
3355
- const normalizedPairPath = normalizeMapKey(pair.filePath);
3356
- const emitted = emitBounded("lsp_scanner_coverage_gap", `${pair.serverId}:${normalizedPairPath}`, {
3357
- filePath: normalizedPairPath,
3358
- durationMs: 0,
3359
- metadata: {
3360
- source: "late-auxiliary",
3361
- serverIds: [pair.serverId],
3362
- reason: "notify-stall-replacement-unavailable",
3363
- reRaised: true,
3364
- },
3365
- }, {
3366
- ledgerKind: "lsp-scanner-coverage-gap",
3367
- reason: "notify-stall replacement was not available before late-coverage ceiling",
3368
- capPerTurn: {
3369
- limit: LATE_AUX_COVERAGE_GAP_DETAIL_CAP_PER_TURN,
3370
- turnIndex: runtime.turnIndex,
3371
- },
3372
- });
3373
- if (emitted)
3374
- lateAuxCoverageGapDetailCount += 1;
3375
- else
3376
- lateAuxCoverageGapDropCount += 1;
3377
- }
3378
- logLatency({
3379
- type: "phase",
3380
- toolName: "turn_end",
3381
- filePath: cwd,
3382
- phase: "late_auxiliary_findings",
3383
- durationMs: Date.now() - lateAuxStart,
3384
- metadata: {
3385
- pending: drainedPairs.length,
3386
- pairCreated: drainedPairs.length + lateAuxCapEvicted,
3387
- pendingAfter: pendingAuxiliaryCoverageSize(),
3388
- delivered: lateAuxDelivered,
3389
- stale: lateAuxStale,
3390
- missing: lateAuxMissing,
3391
- rearmed: lateAuxRearmed,
3392
- clientGone: lateAuxClientGone,
3393
- probeFailed: lateAuxProbeFailed,
3394
- cleanConfirmed: lateAuxCleanConfirmed,
3395
- expired: lateAuxExpired,
3396
- ceilingExhausted: lateAuxCeilingExhausted,
3397
- answered: lateAuxAnswered,
3398
- dispositionSuppressed: lateAuxDispositionSuppressed,
3399
- auxSuppressed: lateAuxAuxSuppressed,
3400
- notifyStallDemoted: lateAuxNotifyStallDemoted,
3401
- coverageGapReRaised: lateAuxCoverageGapPairs.length,
3402
- coverageGapReRaisedDetailed: lateAuxCoverageGapDetailCount,
3403
- coverageGapReRaisedDropped: lateAuxCoverageGapDropCount,
3404
- capEvicted: lateAuxCapEvicted,
3405
- stuckPairs: lateAuxStuckPairs,
3406
- },
3407
- });
3408
- }
3409
- const labeledAdvisoryParts = advisoryParts.map((p) => `ℹ️ Advisory — no action required this turn:\n${p}`);
3410
- // Stale-secret parts sit between the two tiers and are NOT relabelled — they
3411
- // ship the imperative preamble they were built with (#1622 review M2).
3412
- const findingParts = [
3413
- ...blockerParts,
3414
- ...staleSecretParts,
3415
- ...labeledAdvisoryParts,
3416
- ];
3417
- if (findingParts.length > 0) {
3418
- dbg(`turn_end: ${blockerParts.length} blocker section(s), ${advisoryParts.length} advisory section(s) found, persisting for next context`);
3419
- const content = capTurnEndMessage(findingParts.join("\n\n"));
3420
- const signature = `${files
3421
- .slice()
3422
- .sort((a, b) => compareOrdinal(a, b))
3423
- .join("|")}::${content}`;
3424
- const last = cacheManager.readCache("turn-end-findings-last", cwd);
3425
- if (last?.data?.signature === signature &&
3426
- last?.data?.sessionId === runtime.telemetrySessionId) {
3427
- dbg("turn_end: duplicate findings detected (same session), suppressing re-prompt");
3428
- if (getFlag("lens-guard")) {
3429
- const existingGuard = cacheManager.readCache("turn-end-findings", cwd)?.data;
3430
- if (existingGuard) {
3431
- writeGitGuardRecord(cacheManager, runtime, cwd, {
3432
- ...existingGuard,
3433
- content,
3434
- blockerContent: blockerParts.length > 0
3435
- ? capTurnEndMessage(blockerParts.join("\n\n"))
3436
- : undefined,
3437
- hasBlockers: blockerParts.length > 0 || existingGuard.testFailures === true,
3438
- blockingFiles: blockerParts.length > 0 ? existingGuard.affectedFiles : undefined,
3439
- projectSeqStart: runtime.turnStartProjectSeq,
3440
- projectSeqEnd: runtime.projectSeq,
3441
- fileSeqByPath: Object.fromEntries(runtime
3442
- .getFileSeqEntries()
3443
- .map(([filePath, seq]) => [
3444
- normalizeMapKey(path.resolve(filePath)),
3445
- seq,
3446
- ])),
3447
- fileContentHashes: {},
3448
- consumed: false,
3449
- });
3450
- }
3451
- }
3452
- clearOwnedTurnState();
3453
- runtime.fixedThisTurn.clear();
3454
- resetFormatService();
3455
- return;
3456
- }
3457
- // #1950 fix-round F1: this turn's content is confirmed NOT suppressed —
3458
- // it is about to reach the agent — so NOW commit the delivery-count
3459
- // increments the per-blocker loop above only tentatively computed.
3460
- for (const commit of pendingDependencyDriftDeliveries)
3461
- commit();
3462
- const fileSeqByPath = {};
3463
- for (const [filePath, seq] of runtime.getFileSeqEntries()) {
3464
- fileSeqByPath[normalizeMapKey(path.resolve(filePath))] = seq;
3465
- }
3466
- if (getFlag("lens-guard")) {
3467
- const existingGuard = cacheManager.readCache("turn-end-findings", cwd)?.data;
3468
- const blockingContent = blockerParts.length > 0
3469
- ? capTurnEndMessage(blockerParts.join("\n\n"))
3470
- : undefined;
3471
- const affectedFiles = [
3472
- ...(existingGuard?.affectedFiles ?? []),
3473
- ...files.map((file) => resolveRunnerPath(cwd, file)),
3474
- ...cascadeResults.flatMap((result) => result.neighbors
3475
- .filter((neighbor) => neighbor.diagnostics.length > 0)
3476
- .map((neighbor) => resolveRunnerPath(cwd, neighbor.filePath))),
3477
- ];
3478
- writeGitGuardRecord(cacheManager, runtime, cwd, {
3479
- content: [content, existingGuard?.testFailureContent]
3480
- .filter((value) => !!value)
3481
- .join("\n\n"),
3482
- blockerContent: blockingContent,
3483
- blockingFiles: blockerParts.length > 0 ? affectedFiles : undefined,
3484
- hasBlockers: !!blockingContent || existingGuard?.testFailures === true,
3485
- affectedFiles,
3486
- sessionId: runtime.telemetrySessionId,
3487
- projectSeqStart: runtime.turnStartProjectSeq,
3488
- projectSeqEnd: runtime.projectSeq,
3489
- fileSeqByPath,
3490
- fileContentHashes: {},
3491
- consumed: false,
3492
- testFailures: existingGuard?.testFailures,
3493
- testFailureContent: existingGuard?.testFailureContent,
3494
- testFailureFiles: existingGuard?.testFailureFiles,
3495
- });
3496
- }
3497
- else {
3498
- const allAffectedFiles = [
3499
- ...files.map((file) => resolveRunnerPath(cwd, file)),
3500
- ...cascadeResults.flatMap((result) => result.neighbors
3501
- .filter((neighbor) => neighbor.diagnostics.length > 0)
3502
- .map((neighbor) => resolveRunnerPath(cwd, neighbor.filePath))),
3503
- ];
3504
- const affectedFiles = [...new Set(allAffectedFiles)].slice(0, MAX_ADVISORY_AFFECTED_FILES);
3505
- const affectedFilesTruncated = new Set(allAffectedFiles).size > affectedFiles.length;
3506
- cacheManager.writeCache("turn-end-findings", {
3507
- content,
3508
- affectedFiles,
3509
- affectedFilesTruncated,
3510
- provenance: snapshotAdvisoryProvenance({
3511
- cwd,
3512
- runtime,
3513
- generation: 0,
3514
- files: affectedFiles.map((file) => ({
3515
- path: file,
3516
- role: "affected",
3517
- })),
3518
- truncated: affectedFilesTruncated,
3519
- }),
3520
- }, cwd);
3521
- }
3522
- cacheManager.writeCache("turn-end-findings-last", {
3523
- signature,
3524
- sessionId: runtime.telemetrySessionId,
3525
- projectSeqStart: runtime.turnStartProjectSeq,
3526
- projectSeqEnd: runtime.projectSeq,
3527
- }, cwd);
3528
- emitLensTurnFindings({
3529
- cwd,
3530
- filePaths: files.map((file) => resolveRunnerPath(cwd, file)),
3531
- sessionId: runtime.telemetrySessionId,
3532
- turnIndex: runtime.turnIndex,
3533
- blockerSections: blockerParts.length,
3534
- advisorySections: advisoryParts.length,
3535
- content,
3536
- });
3537
- }
3538
- if (blockerParts.length === 0) {
3539
- clearOwnedTurnState();
3540
- // `staleSecretParts` counts here too (#1622 review M2): clearing the
3541
- // findings record while a stale secret is still unverified would drop the
3542
- // only surviving trace of it.
3543
- if (getFlag("lens-guard") &&
3544
- advisoryParts.length === 0 &&
3545
- staleSecretParts.length === 0 &&
3546
- !runtime.gitGuardHasBlockers) {
3547
- const guardRecord = cacheManager.readCache("turn-end-findings", cwd)?.data;
3548
- if (guardRecord?.sessionId === runtime.telemetrySessionId &&
3549
- guardRecord.testFailures !== true) {
3550
- cacheManager.clearCache("turn-end-findings", cwd);
3551
- }
3552
- }
3553
- }
3554
- runtime.fixedThisTurn.clear();
3555
- runtime.clearActionableWarnings();
3556
- runtime.clearCodeQualityWarnings();
3557
- if (demotedFindingsRetired > 0) {
3558
- // #1944: the retired payload must not survive as a SUPPRESSION key.
3559
- // `turn-end-findings-last` holds a content signature used to silence a
3560
- // duplicate re-prompt; live evidence found the demoted payload still on
3561
- // disk 80+ minutes after the file shrank. It is not a delivery source —
3562
- // `runtime-context.ts` never reads it, and the read at the top of this
3563
- // function is gated on the current `sessionId`, so it cannot resurrect
3564
- // the finding in a later session. It CAN, however, silence a genuinely
3565
- // new report of content the store no longer holds. Drop it with the
3566
- // record it describes.
3567
- cacheManager.clearCache("turn-end-findings-last", cwd);
3568
- }
3569
- logLatency({
3570
- type: "tool_result",
3571
- toolName: "turn_end",
3572
- filePath: cwd,
3573
- durationMs: Date.now() - turnEndStart,
3574
- // #1622 review M2: a pending stale secret is NOT a clean turn. It gets its
3575
- // own result rather than being promoted to `blockers_found`, which would
3576
- // undo the demotion the freshness gate just made.
3577
- result: blockerParts.length > 0
3578
- ? "blockers_found"
3579
- : staleSecretParts.length > 0
3580
- ? "stale_secrets_pending"
3581
- : "clean",
3582
- metadata: {
3583
- fileCount: files.length,
3584
- blockerSections: blockerParts.length,
3585
- staleSecretSections: staleSecretParts.length,
3586
- advisorySections: advisoryParts.length,
3587
- // #1944 AC3: an empty advisory section on its own cannot say whether
3588
- // the turn had nothing to report or dropped something. This counter
3589
- // answers that from latency.log even when the payload is empty, and
3590
- // the payload itself carries the retirement note when it is not.
3591
- demotedFindingsRetired,
3592
- // #2275: the widget-footer store's own dependency-drift retirements —
3593
- // a separate surface/counter from `demotedFindingsRetired` above,
3594
- // since a widget retirement never touches `advisoryParts` or the
3595
- // `turn-end-findings-last` suppression cache the block below clears.
3596
- widgetDemotedFindingsRetired,
3597
- },
3598
- });
3599
- resetFormatService();
3600
- }