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.
- package/CHANGELOG.md +1562 -0
- package/README.md +157 -24
- package/config/dependency-cruiser-eager-allowlist.json +6 -2
- package/dist/chunk-22AVIGXS.js +560 -0
- package/dist/chunk-27BBHSZC.js +106 -0
- package/dist/chunk-2BEMIWEW.js +51 -0
- package/dist/chunk-2EECBNC5.js +89 -0
- package/dist/chunk-34U7PLGE.js +23 -0
- package/dist/chunk-3I5QFT3N.js +181 -0
- package/dist/chunk-3MED75HT.js +37 -0
- package/dist/chunk-47D2BO4S.js +87 -0
- package/dist/chunk-4RERDEXR.js +348 -0
- package/dist/chunk-4W2PLC4J.js +27 -0
- package/dist/chunk-52H5B5TC.js +202 -0
- package/dist/chunk-54B42YRX.js +27 -0
- package/dist/chunk-5RXBNSW2.js +266 -0
- package/dist/chunk-5THXD6X5.js +201 -0
- package/dist/chunk-5W3R6LFT.js +24 -0
- package/dist/chunk-5ZEJHV35.js +341 -0
- package/dist/chunk-6P62QVEA.js +118 -0
- package/dist/chunk-6QIUX4EP.js +459 -0
- package/dist/chunk-6VCD4VKZ.js +78 -0
- package/dist/chunk-7F72PXY2.js +326 -0
- package/dist/chunk-7FBF7NPR.js +998 -0
- package/dist/chunk-7U2IDNXN.js +37 -0
- package/dist/chunk-ABBOA7UD.js +157 -0
- package/dist/chunk-AHDW3GPE.js +50 -0
- package/dist/chunk-AJ3QXTRQ.js +756 -0
- package/dist/chunk-AJVLWNFN.js +1610 -0
- package/dist/chunk-AJXODZY6.js +112 -0
- package/dist/chunk-ARFCXG7S.js +84 -0
- package/dist/chunk-AS44CFQ4.js +708 -0
- package/dist/chunk-AWE5TMPO.js +38 -0
- package/dist/chunk-B3I2UUT4.js +4692 -0
- package/dist/chunk-BRJKOBZP.js +331 -0
- package/dist/chunk-BW5KFIQC.js +59 -0
- package/dist/chunk-C2AUYXQH.js +149 -0
- package/dist/chunk-CGGAIP7J.js +29 -0
- package/dist/chunk-D6QS3U6M.js +30 -0
- package/dist/chunk-DFVMIKMQ.js +349 -0
- package/dist/chunk-DIULIWKU.js +169 -0
- package/dist/chunk-DQ2NZ7C4.js +2317 -0
- package/dist/chunk-E2WFBRHM.js +213 -0
- package/dist/chunk-E4MAEDPM.js +50 -0
- package/dist/chunk-EOKRQ3SA.js +30 -0
- package/dist/chunk-F3AU2TMM.js +2626 -0
- package/dist/chunk-F3P7VQ7H.js +170 -0
- package/dist/chunk-FC4RP4I2.js +12 -0
- package/dist/chunk-FJNKANOS.js +32 -0
- package/dist/chunk-GUOGVTCQ.js +146 -0
- package/dist/chunk-GVXA6CCE.js +18 -0
- package/dist/chunk-HHYAELLJ.js +2334 -0
- package/dist/chunk-HMFXILPV.js +83 -0
- package/dist/chunk-HVUZXSWK.js +421 -0
- package/dist/chunk-HWBZIGMJ.js +62 -0
- package/dist/chunk-I3R7GKDR.js +17 -0
- package/dist/chunk-I7SPGOWI.js +55 -0
- package/dist/chunk-IIXOAGHR.js +38 -0
- package/dist/chunk-IVP5KAB7.js +2250 -0
- package/dist/chunk-IXEBJSUE.js +22 -0
- package/dist/chunk-JOLITGCC.js +27 -0
- package/dist/chunk-K6VYLB2U.js +145 -0
- package/dist/chunk-LJDODBBZ.js +78 -0
- package/dist/chunk-LNAKEO63.js +78 -0
- package/dist/chunk-LSMJ6KBE.js +4139 -0
- package/dist/chunk-LW6XKDZQ.js +5565 -0
- package/dist/chunk-N3YQJI6O.js +899 -0
- package/dist/chunk-NABLC7W3.js +148 -0
- package/dist/chunk-NKOUFPQF.js +659 -0
- package/dist/chunk-NL5GLMFI.js +211 -0
- package/dist/chunk-NMABWBZN.js +327 -0
- package/dist/chunk-NTHH2J74.js +27 -0
- package/dist/chunk-NTUJEGDZ.js +59 -0
- package/dist/chunk-NXL4FFQQ.js +47 -0
- package/dist/chunk-NXPS2L6U.js +466 -0
- package/dist/chunk-O5WILGMI.js +42 -0
- package/dist/chunk-O6TQT6RI.js +48 -0
- package/dist/chunk-OAXNDZJ7.js +323 -0
- package/dist/chunk-ODEEOXPH.js +44 -0
- package/dist/chunk-OQGFCZJ4.js +4533 -0
- package/dist/chunk-ORTMY4Q4.js +282 -0
- package/dist/chunk-OS32RNR2.js +442 -0
- package/dist/chunk-PAK6IDME.js +189 -0
- package/dist/chunk-PWFHBTSF.js +406 -0
- package/dist/chunk-Q5U6FMDI.js +2352 -0
- package/dist/chunk-QOZXJ4NA.js +3364 -0
- package/dist/chunk-QYF5S6P6.js +45 -0
- package/dist/chunk-RIISMHKN.js +36 -0
- package/dist/chunk-RPD4ZSB7.js +25 -0
- package/dist/chunk-RPD5K5NC.js +79 -0
- package/dist/chunk-RUOG5D2C.js +262 -0
- package/dist/chunk-T4ERT73Y.js +599 -0
- package/dist/chunk-T6D4Q4AQ.js +54 -0
- package/dist/chunk-TGPXLY2J.js +391 -0
- package/dist/chunk-TLGFBM3F.js +12116 -0
- package/dist/chunk-TO6WL3GM.js +88 -0
- package/dist/chunk-TT22N74J.js +517 -0
- package/dist/chunk-TVN5N6GY.js +21216 -0
- package/dist/chunk-U3VGR4VU.js +308 -0
- package/dist/chunk-UK3CMEAL.js +1250 -0
- package/dist/chunk-UOPVJQ4N.js +103 -0
- package/dist/chunk-UXVNL3D7.js +23 -0
- package/dist/chunk-UY752QSN.js +45 -0
- package/dist/chunk-V5PXFSZK.js +188 -0
- package/dist/chunk-V7R425XG.js +398 -0
- package/dist/chunk-VCSRSYLF.js +81 -0
- package/dist/chunk-VD4JQCQC.js +162 -0
- package/dist/chunk-VDBPVIKD.js +62 -0
- package/dist/chunk-VHQLPIRJ.js +114 -0
- package/dist/chunk-VN2AXLEX.js +5414 -0
- package/dist/chunk-VNMT7C64.js +478 -0
- package/dist/chunk-WONERJTP.js +309 -0
- package/dist/chunk-XECLWQZN.js +85 -0
- package/dist/chunk-XIMYZ3JA.js +27 -0
- package/dist/chunk-XK4CIILI.js +144 -0
- package/dist/chunk-Y4T27COV.js +562 -0
- package/dist/chunk-YGVYKNSN.js +1860 -0
- package/dist/chunk-YLF3PPT5.js +308 -0
- package/dist/chunk-YSNFMJKN.js +14 -0
- package/dist/chunk-Z3CCVDUB.js +76 -0
- package/dist/chunk-Z54ZSOBZ.js +61 -0
- package/dist/chunk-ZAWW3AF6.js +97 -0
- package/dist/chunk-ZMOL6RQC.js +167 -0
- package/dist/index.js +19039 -8956
- package/dist/mcp/analyze-cli.js +186 -228
- package/dist/mcp/cli.js +137 -98
- package/dist/mcp/server.js +22592 -1564
- package/dist/mcp/worker.js +97 -38
- package/dist/probes/installer.js +135 -0
- package/dist/workers/project-snapshot-persist-worker.js +22 -0
- package/dist/workers/review-graph-persist-worker.js +15 -0
- package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
- package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
- package/docs/adr/0003-git-guard-latch-writer.md +203 -0
- package/docs/adr/0004-disposition-policy-seam.md +38 -0
- package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
- package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
- package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
- package/docs/adr/0008-turn-end-lane-interface.md +165 -0
- package/docs/adr/0009-reported-path-attribution.md +114 -0
- package/docs/adr/README.md +26 -0
- package/docs/agent-guide.md +13 -4
- package/docs/agent-tools.md +60 -1
- package/docs/ast-grep_rules_catalog.md +6 -4
- package/docs/configuration.md +75 -2
- package/docs/engineering-principles.md +202 -0
- package/docs/environment-variables.md +113 -6
- package/docs/features.md +10 -6
- package/docs/globalconfig.md +7 -1
- package/docs/io-bridge-v2-migration.md +94 -0
- package/docs/language-coverage.md +17 -0
- package/docs/lsp-capability-matrix.md +131 -32
- package/docs/lsp-idle-eviction.md +124 -0
- package/docs/mcp.md +21 -5
- package/docs/merge-queue-rollout.md +77 -0
- package/docs/pi-lens-fixer.md +161 -39
- package/docs/pi-lens-investigator.md +77 -20
- package/docs/pi-lens-merge-policy.md +694 -0
- package/docs/pi-lens-monitor.md +73 -7
- package/docs/pi-lens-retro.md +6 -4
- package/docs/pi-lens-reviewer.md +190 -34
- package/docs/pi-lens-subagent.md +247 -37
- package/docs/pi-lens-warden.md +18 -7
- package/docs/public-api-stability.md +56 -0
- package/docs/release-qa-baseline.md +86 -17
- package/docs/rfcs/3654-unified-io-bridge-v2.md +316 -0
- package/docs/servercapabilities.md +66 -52
- package/docs/settings.md +13 -3
- package/docs/subagent-compat.md +1 -1
- package/docs/usage.md +5 -1
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-bash.wasm.json +3 -3
- package/package.json +23 -9
- package/rules/ast-grep-rules/rule-tests/advisory-registry-subset-test.yml +41 -0
- package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
- package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
- package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
- package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
- package/rules/ast-grep-rules/rules/advisory-registry-subset.yml +50 -0
- package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
- package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
- package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
- package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
- package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
- package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
- package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
- package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
- package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
- package/scripts/analyze-pi-lens-logs.mjs +1275 -62
- package/scripts/download-grammars.js +5 -0
- package/scripts/grammars.lock.json +6 -1
- package/scripts/install-selftest.mjs +38 -35
- package/scripts/lib/host-provided-deps.mjs +13 -4
- package/scripts/lib/web-tree-sitter-dir.mjs +141 -0
- package/dist/clients/actionable-warnings-logger.js +0 -35
- package/dist/clients/actionable-warnings.js +0 -1594
- package/dist/clients/advisory-provenance.js +0 -457
- package/dist/clients/agent-behavior-client.js +0 -147
- package/dist/clients/agent-nudge.js +0 -325
- package/dist/clients/analysed-root.js +0 -1
- package/dist/clients/ast-grep-client.js +0 -731
- package/dist/clients/ast-grep-rule-manager.js +0 -146
- package/dist/clients/ast-grep-tool-logger.js +0 -118
- package/dist/clients/ast-grep-types.js +0 -10
- package/dist/clients/ast-grep-yaml-synth.js +0 -154
- package/dist/clients/atomic-write-staging.js +0 -75
- package/dist/clients/atomic-write.js +0 -189
- package/dist/clients/availability-probe-flight.js +0 -12
- package/dist/clients/bash-file-access.js +0 -1056
- package/dist/clients/biome-client.js +0 -357
- package/dist/clients/blocker-freshness.js +0 -837
- package/dist/clients/blocker-past-eof.js +0 -129
- package/dist/clients/bootstrap.js +0 -569
- package/dist/clients/bounded-cache.js +0 -190
- package/dist/clients/bounded-pid-file-lock.js +0 -202
- package/dist/clients/bounded-telemetry.js +0 -295
- package/dist/clients/build-identity.js +0 -95
- package/dist/clients/bundled-resource-health.js +0 -113
- package/dist/clients/bus-events-logger.js +0 -105
- package/dist/clients/bus-publish.js +0 -171
- package/dist/clients/cache/rule-cache.js +0 -190
- package/dist/clients/cache-manager.js +0 -465
- package/dist/clients/cache-observability.js +0 -1313
- package/dist/clients/call-graph.js +0 -852
- package/dist/clients/cargo-manifest.js +0 -422
- package/dist/clients/cascade-budget.js +0 -196
- package/dist/clients/cascade-format.js +0 -315
- package/dist/clients/cascade-logger.js +0 -31
- package/dist/clients/cascade-types.js +0 -9
- package/dist/clients/child-unref.js +0 -115
- package/dist/clients/code-quality-warnings.js +0 -286
- package/dist/clients/codebase-model.js +0 -173
- package/dist/clients/collateral-test-role.js +0 -40
- package/dist/clients/complexity-client.js +0 -462
- package/dist/clients/config-core/deny.js +0 -221
- package/dist/clients/config-core/index.js +0 -50
- package/dist/clients/config-core/merge.js +0 -357
- package/dist/clients/config-core/normalize.js +0 -340
- package/dist/clients/config-core/process-spec.js +0 -248
- package/dist/clients/config-core/provenance.js +0 -164
- package/dist/clients/config-core/records.js +0 -218
- package/dist/clients/config-core/resolve.js +0 -125
- package/dist/clients/config-core/safe-object.js +0 -78
- package/dist/clients/config-core/schema.js +0 -165
- package/dist/clients/config-diagnostic-codes.js +0 -283
- package/dist/clients/config-locations.js +0 -160
- package/dist/clients/config-resolve.js +0 -792
- package/dist/clients/config-schema.js +0 -251
- package/dist/clients/config-warn.js +0 -407
- package/dist/clients/console-guard-install.js +0 -26
- package/dist/clients/cooperative-budget.js +0 -42
- package/dist/clients/dead-code-client.js +0 -510
- package/dist/clients/dead-code-logger.js +0 -30
- package/dist/clients/deadline-utils.js +0 -280
- package/dist/clients/debug-handles.js +0 -211
- package/dist/clients/debug-heap.js +0 -174
- package/dist/clients/deferred-lsp-work.js +0 -106
- package/dist/clients/degradation-ledger.js +0 -396
- package/dist/clients/demoted-finding-render.js +0 -169
- package/dist/clients/dependency-checker.js +0 -862
- package/dist/clients/deps/ast-grep-napi.js +0 -22
- package/dist/clients/deps/js-yaml.js +0 -9
- package/dist/clients/deps/minimatch.js +0 -4
- package/dist/clients/deps/pi-tui.js +0 -14
- package/dist/clients/deps/typebox.js +0 -14
- package/dist/clients/deps/vscode-jsonrpc.js +0 -1
- package/dist/clients/deps/web-tree-sitter.js +0 -24
- package/dist/clients/diagnostic-dispositions.js +0 -594
- package/dist/clients/diagnostic-line-freshness.js +0 -305
- package/dist/clients/diagnostic-logger.js +0 -73
- package/dist/clients/diagnostic-tracker.js +0 -105
- package/dist/clients/diagnostics-publish.js +0 -230
- package/dist/clients/dispatch/ast-grep-catalog.js +0 -65
- package/dist/clients/dispatch/auxiliary-lsp.js +0 -343
- package/dist/clients/dispatch/collect-later-tier.js +0 -35
- package/dist/clients/dispatch/diagnostic-taxonomy.js +0 -66
- package/dist/clients/dispatch/dispatcher.js +0 -1269
- package/dist/clients/dispatch/fact-provider-types.js +0 -1
- package/dist/clients/dispatch/fact-rule-runner.js +0 -17
- package/dist/clients/dispatch/fact-runner.js +0 -30
- package/dist/clients/dispatch/fact-scheduler.js +0 -68
- package/dist/clients/dispatch/fact-store.js +0 -377
- package/dist/clients/dispatch/facts/comment-facts.js +0 -22
- package/dist/clients/dispatch/facts/file-content.js +0 -19
- package/dist/clients/dispatch/facts/function-facts.js +0 -348
- package/dist/clients/dispatch/facts/import-facts.js +0 -204
- package/dist/clients/dispatch/facts/tree-sitter-facts.js +0 -34
- package/dist/clients/dispatch/facts/try-catch-facts.js +0 -119
- package/dist/clients/dispatch/finding-policy.js +0 -222
- package/dist/clients/dispatch/indent-detect.js +0 -411
- package/dist/clients/dispatch/inline-suppressions.js +0 -70
- package/dist/clients/dispatch/integration.js +0 -2377
- package/dist/clients/dispatch/lazy.js +0 -11
- package/dist/clients/dispatch/pending-runner-findings.js +0 -87
- package/dist/clients/dispatch/plan.js +0 -278
- package/dist/clients/dispatch/priorities.js +0 -20
- package/dist/clients/dispatch/rule-id-normalize.js +0 -50
- package/dist/clients/dispatch/rule-ignores.js +0 -62
- package/dist/clients/dispatch/rule-policy.js +0 -135
- package/dist/clients/dispatch/rules/async-noise.js +0 -40
- package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +0 -27
- package/dist/clients/dispatch/rules/cors-wildcard.js +0 -54
- package/dist/clients/dispatch/rules/error-obscuring.js +0 -32
- package/dist/clients/dispatch/rules/error-swallowing.js +0 -31
- package/dist/clients/dispatch/rules/framework-call-noise.js +0 -67
- package/dist/clients/dispatch/rules/high-complexity.js +0 -72
- package/dist/clients/dispatch/rules/high-fan-out.js +0 -74
- package/dist/clients/dispatch/rules/high-import-coupling.js +0 -37
- package/dist/clients/dispatch/rules/missing-error-propagation.js +0 -62
- package/dist/clients/dispatch/rules/no-commented-credentials.js +0 -52
- package/dist/clients/dispatch/rules/pass-through-wrappers.js +0 -42
- package/dist/clients/dispatch/rules/placeholder-comments.js +0 -40
- package/dist/clients/dispatch/rules/unsafe-boundary.js +0 -126
- package/dist/clients/dispatch/runner-context.js +0 -52
- package/dist/clients/dispatch/runners/actionlint.js +0 -113
- package/dist/clients/dispatch/runners/ast-grep-napi.js +0 -1196
- package/dist/clients/dispatch/runners/biome-check.js +0 -283
- package/dist/clients/dispatch/runners/cpp-check.js +0 -236
- package/dist/clients/dispatch/runners/credo.js +0 -80
- package/dist/clients/dispatch/runners/cue-vet.js +0 -398
- package/dist/clients/dispatch/runners/dart-analyze.js +0 -202
- package/dist/clients/dispatch/runners/detekt.js +0 -169
- package/dist/clients/dispatch/runners/dotnet-build.js +0 -159
- package/dist/clients/dispatch/runners/elixir-check.js +0 -210
- package/dist/clients/dispatch/runners/eslint.js +0 -154
- package/dist/clients/dispatch/runners/fact-rules.js +0 -29
- package/dist/clients/dispatch/runners/fish-indent.js +0 -69
- package/dist/clients/dispatch/runners/gleam-check.js +0 -87
- package/dist/clients/dispatch/runners/go-vet.js +0 -90
- package/dist/clients/dispatch/runners/golangci-lint.js +0 -120
- package/dist/clients/dispatch/runners/hadolint.js +0 -79
- package/dist/clients/dispatch/runners/helm-lint.js +0 -171
- package/dist/clients/dispatch/runners/helm-render.js +0 -940
- package/dist/clients/dispatch/runners/htmlhint.js +0 -108
- package/dist/clients/dispatch/runners/index.js +0 -108
- package/dist/clients/dispatch/runners/javac.js +0 -89
- package/dist/clients/dispatch/runners/ktlint.js +0 -136
- package/dist/clients/dispatch/runners/lsp.js +0 -378
- package/dist/clients/dispatch/runners/markdownlint.js +0 -160
- package/dist/clients/dispatch/runners/mypy.js +0 -101
- package/dist/clients/dispatch/runners/oxlint.js +0 -398
- package/dist/clients/dispatch/runners/php-lint.js +0 -62
- package/dist/clients/dispatch/runners/phpstan.js +0 -120
- package/dist/clients/dispatch/runners/prisma-validate.js +0 -62
- package/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -445
- package/dist/clients/dispatch/runners/pyright.js +0 -169
- package/dist/clients/dispatch/runners/rubocop.js +0 -90
- package/dist/clients/dispatch/runners/ruff.js +0 -89
- package/dist/clients/dispatch/runners/rust-clippy.js +0 -251
- package/dist/clients/dispatch/runners/shellcheck.js +0 -169
- package/dist/clients/dispatch/runners/shfmt.js +0 -102
- package/dist/clients/dispatch/runners/spellcheck.js +0 -121
- package/dist/clients/dispatch/runners/spotbugs.js +0 -259
- package/dist/clients/dispatch/runners/sqlfluff.js +0 -174
- package/dist/clients/dispatch/runners/stylelint.js +0 -158
- package/dist/clients/dispatch/runners/swiftlint.js +0 -154
- package/dist/clients/dispatch/runners/taplo.js +0 -168
- package/dist/clients/dispatch/runners/terragrunt.js +0 -168
- package/dist/clients/dispatch/runners/tflint.js +0 -110
- package/dist/clients/dispatch/runners/tree-sitter.js +0 -620
- package/dist/clients/dispatch/runners/trivy-config.js +0 -247
- package/dist/clients/dispatch/runners/utils/availability-policy.js +0 -496
- package/dist/clients/dispatch/runners/utils/candidate-probe.js +0 -90
- package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +0 -85
- package/dist/clients/dispatch/runners/utils/lazy-installer.js +0 -274
- package/dist/clients/dispatch/runners/utils/runner-helpers.js +0 -1701
- package/dist/clients/dispatch/runners/utils/spawn-outcome.js +0 -101
- package/dist/clients/dispatch/runners/utils/tool-failure.js +0 -235
- package/dist/clients/dispatch/runners/utils/toolchain-availability.js +0 -169
- package/dist/clients/dispatch/runners/utils.js +0 -15
- package/dist/clients/dispatch/runners/vale.js +0 -116
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +0 -242
- package/dist/clients/dispatch/runners/yamllint.js +0 -85
- package/dist/clients/dispatch/runners/zig-check.js +0 -91
- package/dist/clients/dispatch/suppress-writer.js +0 -69
- package/dist/clients/dispatch/tool-profile.js +0 -30
- package/dist/clients/dispatch/types.js +0 -21
- package/dist/clients/dispatch/utils/format-utils.js +0 -45
- package/dist/clients/dispatch/utils/lsp-diagnostics.js +0 -35
- package/dist/clients/disposition-logger.js +0 -32
- package/dist/clients/disposition-publish.js +0 -124
- package/dist/clients/durable-store.js +0 -97
- package/dist/clients/effective-config.js +0 -413
- package/dist/clients/env-utils.js +0 -83
- package/dist/clients/error-class.js +0 -23
- package/dist/clients/eval-timestamp.js +0 -17
- package/dist/clients/event-loop-hold.js +0 -274
- package/dist/clients/event-loop-monitor.js +0 -250
- package/dist/clients/extension-log.js +0 -504
- package/dist/clients/extension-mode.js +0 -73
- package/dist/clients/feature-hints.js +0 -103
- package/dist/clients/file-kinds.js +0 -412
- package/dist/clients/file-role.js +0 -134
- package/dist/clients/file-time.js +0 -160
- package/dist/clients/file-utils.js +0 -1032
- package/dist/clients/finding-delivery-gate.js +0 -463
- package/dist/clients/finding-identity.js +0 -88
- package/dist/clients/fix-worklog.js +0 -103
- package/dist/clients/format-events-publish.js +0 -343
- package/dist/clients/format-service.js +0 -222
- package/dist/clients/formatters-lazy.js +0 -9
- package/dist/clients/formatters.js +0 -1994
- package/dist/clients/freshness-cadence.js +0 -17
- package/dist/clients/freshness.js +0 -38
- package/dist/clients/generated-artifacts.js +0 -394
- package/dist/clients/generation-guard.js +0 -215
- package/dist/clients/git-guard.js +0 -923
- package/dist/clients/git-tracked-ignore.js +0 -243
- package/dist/clients/gitleaks-client.js +0 -509
- package/dist/clients/go-client.js +0 -104
- package/dist/clients/govulncheck-client.js +0 -605
- package/dist/clients/gradle-ktfmt-style.js +0 -252
- package/dist/clients/grammar-source.js +0 -465
- package/dist/clients/gzip-stage-write.js +0 -135
- package/dist/clients/hashline-anchor.js +0 -424
- package/dist/clients/hook-budgets.js +0 -76
- package/dist/clients/host-edit-normalize.js +0 -140
- package/dist/clients/host-ports.js +0 -39
- package/dist/clients/indent-retarget.js +0 -154
- package/dist/clients/install-diagnostics.js +0 -143
- package/dist/clients/installer/index.js +0 -5167
- package/dist/clients/installer/managed-tool-refresh-session.js +0 -58
- package/dist/clients/installer/managed-tool-refresh.js +0 -783
- package/dist/clients/instance-reaper-state.js +0 -7
- package/dist/clients/instance-reaper.js +0 -1137
- package/dist/clients/instance-registry-lock.js +0 -145
- package/dist/clients/instance-registry.js +0 -773
- package/dist/clients/jscpd-client.js +0 -370
- package/dist/clients/json-cache-read.js +0 -65
- package/dist/clients/knip-client.js +0 -788
- package/dist/clients/language-policy.js +0 -252
- package/dist/clients/language-profile.js +0 -361
- package/dist/clients/language-registry.js +0 -603
- package/dist/clients/latency-logger.js +0 -719
- package/dist/clients/lazy-import.js +0 -21
- package/dist/clients/ledger-bounds.js +0 -54
- package/dist/clients/lens-config.js +0 -346
- package/dist/clients/lens-engine.js +0 -358
- package/dist/clients/lens-events.js +0 -125
- package/dist/clients/lens-flag-registry.js +0 -467
- package/dist/clients/lens-map.js +0 -1156
- package/dist/clients/live-bus-emitter.js +0 -74
- package/dist/clients/log-cleanup.js +0 -222
- package/dist/clients/lsp/aggregation.js +0 -178
- package/dist/clients/lsp/cascade-tier.js +0 -421
- package/dist/clients/lsp/client.js +0 -4465
- package/dist/clients/lsp/config.js +0 -689
- package/dist/clients/lsp/diagnostic-binding.js +0 -225
- package/dist/clients/lsp/document-drift.js +0 -467
- package/dist/clients/lsp/edits.js +0 -1219
- package/dist/clients/lsp/index.js +0 -8305
- package/dist/clients/lsp/inferred-project.js +0 -216
- package/dist/clients/lsp/jvm-runtime.js +0 -298
- package/dist/clients/lsp/language.js +0 -63
- package/dist/clients/lsp/launch.js +0 -725
- package/dist/clients/lsp/lombok.js +0 -158
- package/dist/clients/lsp/path-utils.js +0 -5
- package/dist/clients/lsp/pending-aux-coverage.js +0 -251
- package/dist/clients/lsp/position-encoding.js +0 -61
- package/dist/clients/lsp/ruby-drive-dirs.js +0 -83
- package/dist/clients/lsp/server.js +0 -3209
- package/dist/clients/lsp/session-roots.js +0 -192
- package/dist/clients/lsp/spawn-history.js +0 -17
- package/dist/clients/lsp/sync-kind.js +0 -42
- package/dist/clients/lsp/tsserver-sync.js +0 -332
- package/dist/clients/lsp/wait-policy/capability-snapshot.js +0 -1
- package/dist/clients/lsp/wait-policy/classification.js +0 -85
- package/dist/clients/lsp/wait-policy/index.js +0 -10
- package/dist/clients/lsp/wait-policy/strategies.js +0 -303
- package/dist/clients/lsp/watch-queue.js +0 -61
- package/dist/clients/lsp/workspace-diagnostics-cache.js +0 -639
- package/dist/clients/lsp/workspace-diagnostics-session.js +0 -30
- package/dist/clients/lsp/workspace-sweep-hold.js +0 -184
- package/dist/clients/lsp-budget.js +0 -186
- package/dist/clients/lsp-document-symbols.js +0 -110
- package/dist/clients/lsp-lazy.js +0 -9
- package/dist/clients/lsp-mutation.js +0 -396
- package/dist/clients/lsp-status.js +0 -59
- package/dist/clients/map-with-concurrency.js +0 -40
- package/dist/clients/mcp/analyze.js +0 -420
- package/dist/clients/mcp/host-shim.js +0 -31
- package/dist/clients/mcp/ipc.js +0 -335
- package/dist/clients/mcp/review.js +0 -151
- package/dist/clients/mcp/session.js +0 -412
- package/dist/clients/memory-sampler.js +0 -208
- package/dist/clients/message-end-attribution.js +0 -30
- package/dist/clients/metrics-client.js +0 -107
- package/dist/clients/metrics-history.js +0 -327
- package/dist/clients/middle-man-analysis.js +0 -263
- package/dist/clients/model-provider.js +0 -53
- package/dist/clients/module-report-lsp.js +0 -189
- package/dist/clients/module-report.js +0 -2150
- package/dist/clients/mutating-tool.js +0 -651
- package/dist/clients/mutation-attribution.js +0 -368
- package/dist/clients/mutation-bridge.js +0 -240
- package/dist/clients/ndjson-logger.js +0 -734
- package/dist/clients/observed-mutation-sources.js +0 -101
- package/dist/clients/observed-mutation.js +0 -1253
- package/dist/clients/oldtext-autopatch.js +0 -128
- package/dist/clients/opaque-mutation-scan.js +0 -496
- package/dist/clients/opengrep-client.js +0 -328
- package/dist/clients/opengrep-config.js +0 -76
- package/dist/clients/package-manager.js +0 -607
- package/dist/clients/package-root.js +0 -38
- package/dist/clients/partial-edit-apply.js +0 -439
- package/dist/clients/path-attribution-telemetry.js +0 -43
- package/dist/clients/path-keyed-map.js +0 -108
- package/dist/clients/path-utils.js +0 -1244
- package/dist/clients/performance-report.js +0 -331
- package/dist/clients/persist-debounce.js +0 -70
- package/dist/clients/persistent-reverify.js +0 -271
- package/dist/clients/php-cs-fixer-config.js +0 -119
- package/dist/clients/pipeline.js +0 -1425
- package/dist/clients/post-autofix-notice.js +0 -37
- package/dist/clients/print-mode.js +0 -21
- package/dist/clients/probe-home-state.js +0 -228
- package/dist/clients/process-bridge.js +0 -66
- package/dist/clients/process-singletons.js +0 -210
- package/dist/clients/process-snapshot.js +0 -68
- package/dist/clients/project-changes.js +0 -181
- package/dist/clients/project-conventions.js +0 -171
- package/dist/clients/project-diagnostics/cache.js +0 -137
- package/dist/clients/project-diagnostics/extractors.js +0 -109
- package/dist/clients/project-diagnostics/fresh-fetch.js +0 -650
- package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +0 -118
- package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +0 -47
- package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +0 -40
- package/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +0 -34
- package/dist/clients/project-diagnostics/runner-adapters/jscpd.js +0 -38
- package/dist/clients/project-diagnostics/runner-adapters/knip.js +0 -47
- package/dist/clients/project-diagnostics/runner-adapters/madge.js +0 -48
- package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +0 -40
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +0 -127
- package/dist/clients/project-diagnostics/runner-adapters/trivy.js +0 -81
- package/dist/clients/project-diagnostics/scanner.js +0 -520
- package/dist/clients/project-diagnostics/types.js +0 -1
- package/dist/clients/project-lens-config.js +0 -799
- package/dist/clients/project-report.js +0 -701
- package/dist/clients/project-scale.js +0 -237
- package/dist/clients/project-scan-policy.js +0 -11
- package/dist/clients/project-snapshot-fingerprint.js +0 -48
- package/dist/clients/project-snapshot-persist-worker.js +0 -20
- package/dist/clients/project-snapshot.js +0 -1653
- package/dist/clients/project-trust.js +0 -181
- package/dist/clients/python-environment.js +0 -217
- package/dist/clients/python-provenance.js +0 -639
- package/dist/clients/quiet-window-config.js +0 -47
- package/dist/clients/quiet-window.js +0 -236
- package/dist/clients/read-bridge.js +0 -139
- package/dist/clients/read-expansion.js +0 -266
- package/dist/clients/read-guard-logger.js +0 -251
- package/dist/clients/read-guard-tool-lines.js +0 -1306
- package/dist/clients/read-guard.js +0 -1561
- package/dist/clients/recent-touches.js +0 -240
- package/dist/clients/redact/secrets.js +0 -449
- package/dist/clients/resource-sampler.js +0 -628
- package/dist/clients/reverse-deps.js +0 -270
- package/dist/clients/review-graph/builder.js +0 -5118
- package/dist/clients/review-graph/format.js +0 -33
- package/dist/clients/review-graph/git-identity.js +0 -157
- package/dist/clients/review-graph/import-resolvers.js +0 -481
- package/dist/clients/review-graph/persist-worker.js +0 -10
- package/dist/clients/review-graph/query.js +0 -244
- package/dist/clients/review-graph/revision-drift.js +0 -21
- package/dist/clients/review-graph/service.js +0 -96
- package/dist/clients/review-graph/shared-extraction-ir.js +0 -82
- package/dist/clients/review-graph/symbol-id.js +0 -103
- package/dist/clients/review-graph/tsconfig-paths.js +0 -299
- package/dist/clients/review-graph/types.js +0 -1
- package/dist/clients/review-graph/workspace-modules.js +0 -449
- package/dist/clients/review-graph-logger.js +0 -81
- package/dist/clients/ruff-client.js +0 -152
- package/dist/clients/rules-scanner.js +0 -131
- package/dist/clients/run-duration.js +0 -55
- package/dist/clients/runtime-agent-end.js +0 -746
- package/dist/clients/runtime-config.js +0 -77
- package/dist/clients/runtime-context.js +0 -211
- package/dist/clients/runtime-coordinator.js +0 -1290
- package/dist/clients/runtime-session.js +0 -2485
- package/dist/clients/runtime-tool-call.js +0 -1284
- package/dist/clients/runtime-tool-result.js +0 -2034
- package/dist/clients/runtime-turn.js +0 -3600
- package/dist/clients/rust-client.js +0 -97
- package/dist/clients/safe-spawn.js +0 -2166
- package/dist/clients/sanitize.js +0 -286
- package/dist/clients/scan-utils.js +0 -25
- package/dist/clients/scratch-tree-policy.js +0 -165
- package/dist/clients/search-read-registration.js +0 -94
- package/dist/clients/secret-findings.js +0 -114
- package/dist/clients/security-scan-client.js +0 -352
- package/dist/clients/session-event-guard.js +0 -297
- package/dist/clients/session-lifecycle.js +0 -484
- package/dist/clients/session-start-observability.js +0 -94
- package/dist/clients/session-state-store.js +0 -101
- package/dist/clients/session-summary.js +0 -15
- package/dist/clients/sessionstart-logger.js +0 -27
- package/dist/clients/sg-runner.js +0 -876
- package/dist/clients/sgconfig.js +0 -347
- package/dist/clients/shared-checkout-guard.js +0 -338
- package/dist/clients/single-flight.js +0 -158
- package/dist/clients/situational-tool-telemetry.js +0 -108
- package/dist/clients/skills-resolver.js +0 -141
- package/dist/clients/slow-fs.js +0 -138
- package/dist/clients/smells-rollup.js +0 -231
- package/dist/clients/source-filter.js +0 -596
- package/dist/clients/source-walker.js +0 -211
- package/dist/clients/spawn-output-cap.js +0 -37
- package/dist/clients/spawn-timeout-cooldown.js +0 -110
- package/dist/clients/stale-marker.js +0 -17
- package/dist/clients/startup-scan.js +0 -400
- package/dist/clients/startup-timing.js +0 -130
- package/dist/clients/string-utils.js +0 -33
- package/dist/clients/subagent-mode.js +0 -111
- package/dist/clients/symbol-containment.js +0 -24
- package/dist/clients/symbol-types.js +0 -5
- package/dist/clients/test-runner-client.js +0 -2507
- package/dist/clients/test-runner-delivery.js +0 -287
- package/dist/clients/todo-scanner.js +0 -229
- package/dist/clients/tool-agreement.js +0 -423
- package/dist/clients/tool-config.js +0 -249
- package/dist/clients/tool-cwd.js +0 -270
- package/dist/clients/tool-definition.js +0 -47
- package/dist/clients/tool-event.js +0 -105
- package/dist/clients/tool-policy.js +0 -2755
- package/dist/clients/tool-probe.js +0 -53
- package/dist/clients/tool-render.js +0 -156
- package/dist/clients/tool-set-policy.js +0 -118
- package/dist/clients/tree-sitter-cache.js +0 -364
- package/dist/clients/tree-sitter-client.js +0 -4134
- package/dist/clients/tree-sitter-logger.js +0 -100
- package/dist/clients/tree-sitter-navigator.js +0 -269
- package/dist/clients/tree-sitter-query-loader.js +0 -569
- package/dist/clients/tree-sitter-shared.js +0 -158
- package/dist/clients/tree-sitter-symbol-extractor.js +0 -1265
- package/dist/clients/trivy-client.js +0 -467
- package/dist/clients/tui-fit.js +0 -50
- package/dist/clients/turn-context.js +0 -52
- package/dist/clients/turn-summary-render.js +0 -72
- package/dist/clients/turn-summary.js +0 -132
- package/dist/clients/typos-config.js +0 -29
- package/dist/clients/user-notify.js +0 -51
- package/dist/clients/vanished-instance-marker.js +0 -57
- package/dist/clients/warm-attach.js +0 -246
- package/dist/clients/widget-state.js +0 -1677
- package/dist/clients/word-index-logger.js +0 -49
- package/dist/clients/word-index-store.js +0 -459
- package/dist/clients/word-index.js +0 -2080
- package/dist/clients/workspace-topology.js +0 -389
- package/dist/clients/write-ordering-guard.js +0 -65
- package/dist/clients/zizmor-config.js +0 -327
- package/dist/i18n.js +0 -69
- package/dist/mcp/build-staleness.js +0 -124
- package/dist/scripts/lib/process-scan.mjs +0 -583
- package/dist/scripts/lib/skills-predicate.mjs +0 -129
- package/dist/tools/activate-tools.js +0 -87
- package/dist/tools/ast-grep-outline.js +0 -137
- package/dist/tools/ast-grep-replace.js +0 -244
- package/dist/tools/ast-grep-search.js +0 -762
- package/dist/tools/effective-config.js +0 -90
- package/dist/tools/lens-diagnostic-mark.js +0 -375
- package/dist/tools/lens-diagnostics.js +0 -2544
- package/dist/tools/lsp-diagnostics.js +0 -1634
- package/dist/tools/lsp-navigation.js +0 -1367
- package/dist/tools/lsp-structured-output.js +0 -294
- package/dist/tools/module-report.js +0 -382
- package/dist/tools/project-report.js +0 -86
- package/dist/tools/render-compact.js +0 -374
- package/dist/tools/scan-progress.js +0 -53
- package/dist/tools/shared.js +0 -29
- 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
|
-
}
|