pi-lens 4.1.2 → 4.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1308 -0
- package/README.md +3 -0
- package/config/biome/core.jsonc +11 -2
- package/config/dependency-cruiser-eager-allowlist.json +91 -0
- package/dist/clients/actionable-warnings-logger.js +18 -3
- package/dist/clients/actionable-warnings.js +927 -80
- package/dist/clients/agent-behavior-client.js +13 -4
- package/dist/clients/ast-grep-client.js +136 -9
- package/dist/clients/ast-grep-rule-manager.js +60 -5
- package/dist/clients/ast-grep-tool-logger.js +2 -2
- package/dist/clients/availability-probe-flight.js +12 -0
- package/dist/clients/bash-file-access.js +1 -3
- package/dist/clients/biome-client.js +16 -2
- package/dist/clients/blocker-freshness.js +34 -0
- package/dist/clients/bootstrap.js +509 -73
- package/dist/clients/bounded-cache.js +152 -12
- package/dist/clients/bounded-telemetry.js +115 -23
- package/dist/clients/build-identity.js +95 -0
- package/dist/clients/bundled-resource-health.js +113 -0
- package/dist/clients/bus-events-logger.js +2 -2
- package/dist/clients/bus-publish.js +3 -0
- package/dist/clients/cache/rule-cache.js +33 -5
- package/dist/clients/cache-manager.js +105 -6
- package/dist/clients/cache-observability.js +46 -19
- package/dist/clients/cargo-manifest.js +422 -0
- package/dist/clients/cascade-logger.js +17 -3
- package/dist/clients/code-quality-warnings.js +13 -3
- package/dist/clients/complexity-client.js +16 -5
- package/dist/clients/config-core/deny.js +221 -0
- package/dist/clients/config-core/index.js +47 -0
- package/dist/clients/config-core/merge.js +357 -0
- package/dist/clients/config-core/normalize.js +340 -0
- package/dist/clients/config-core/process-spec.js +248 -0
- package/dist/clients/config-core/provenance.js +171 -0
- package/dist/clients/config-core/records.js +218 -0
- package/dist/clients/config-core/resolve.js +125 -0
- package/dist/clients/config-core/safe-object.js +78 -0
- package/dist/clients/config-core/schema.js +167 -0
- package/dist/clients/config-diagnostic-codes.js +281 -0
- package/dist/clients/config-locations.js +160 -0
- package/dist/clients/config-resolve.js +789 -0
- package/dist/clients/config-schema.js +197 -0
- package/dist/clients/config-warn.js +407 -0
- package/dist/clients/dead-code-client.js +33 -25
- package/dist/clients/dead-code-logger.js +2 -2
- package/dist/clients/deadline-utils.js +178 -0
- package/dist/clients/debug-handles.js +2 -2
- package/dist/clients/debug-heap.js +3 -3
- package/dist/clients/deferred-lsp-work.js +106 -0
- package/dist/clients/degradation-ledger.js +166 -8
- package/dist/clients/demoted-finding-render.js +14 -0
- package/dist/clients/dependency-checker.js +28 -22
- package/dist/clients/deps/minimatch.js +1 -1
- package/dist/clients/diagnostic-line-freshness.js +12 -9
- package/dist/clients/diagnostic-logger.js +14 -4
- package/dist/clients/dispatch/collect-later-tier.js +39 -0
- package/dist/clients/dispatch/dispatcher.js +304 -19
- package/dist/clients/dispatch/fact-store.js +328 -9
- package/dist/clients/dispatch/integration.js +214 -107
- package/dist/clients/dispatch/pending-runner-findings.js +87 -0
- package/dist/clients/dispatch/plan.js +21 -5
- package/dist/clients/dispatch/runners/actionlint.js +0 -1
- package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
- package/dist/clients/dispatch/runners/biome-check.js +0 -1
- package/dist/clients/dispatch/runners/cpp-check.js +0 -1
- package/dist/clients/dispatch/runners/credo.js +0 -1
- package/dist/clients/dispatch/runners/cue-vet.js +0 -1
- package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
- package/dist/clients/dispatch/runners/detekt.js +0 -1
- package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
- package/dist/clients/dispatch/runners/elixir-check.js +0 -1
- package/dist/clients/dispatch/runners/eslint.js +5 -2
- package/dist/clients/dispatch/runners/fact-rules.js +0 -1
- package/dist/clients/dispatch/runners/fish-indent.js +0 -1
- package/dist/clients/dispatch/runners/gleam-check.js +0 -1
- package/dist/clients/dispatch/runners/go-vet.js +1 -3
- package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
- package/dist/clients/dispatch/runners/hadolint.js +0 -1
- package/dist/clients/dispatch/runners/helm-lint.js +16 -4
- package/dist/clients/dispatch/runners/helm-render.js +54 -10
- package/dist/clients/dispatch/runners/htmlhint.js +0 -1
- package/dist/clients/dispatch/runners/index.js +1 -3
- package/dist/clients/dispatch/runners/javac.js +0 -1
- package/dist/clients/dispatch/runners/ktlint.js +0 -1
- package/dist/clients/dispatch/runners/lsp.js +37 -2
- package/dist/clients/dispatch/runners/markdownlint.js +4 -2
- package/dist/clients/dispatch/runners/mypy.js +0 -1
- package/dist/clients/dispatch/runners/oxlint.js +24 -22
- package/dist/clients/dispatch/runners/php-lint.js +0 -1
- package/dist/clients/dispatch/runners/phpstan.js +0 -1
- package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
- package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
- package/dist/clients/dispatch/runners/pyright.js +7 -4
- package/dist/clients/dispatch/runners/rubocop.js +0 -1
- package/dist/clients/dispatch/runners/ruff.js +0 -1
- package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
- package/dist/clients/dispatch/runners/shellcheck.js +0 -1
- package/dist/clients/dispatch/runners/shfmt.js +0 -1
- package/dist/clients/dispatch/runners/spellcheck.js +0 -1
- package/dist/clients/dispatch/runners/spotbugs.js +0 -1
- package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
- package/dist/clients/dispatch/runners/stylelint.js +0 -1
- package/dist/clients/dispatch/runners/swiftlint.js +0 -1
- package/dist/clients/dispatch/runners/taplo.js +0 -1
- package/dist/clients/dispatch/runners/terragrunt.js +0 -1
- package/dist/clients/dispatch/runners/tflint.js +0 -1
- package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
- package/dist/clients/dispatch/runners/trivy-config.js +0 -1
- package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
- package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
- package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
- package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
- package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
- package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
- package/dist/clients/dispatch/runners/vale.js +0 -1
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
- package/dist/clients/dispatch/runners/yamllint.js +0 -1
- package/dist/clients/dispatch/runners/zig-check.js +0 -1
- package/dist/clients/disposition-logger.js +3 -3
- package/dist/clients/effective-config.js +403 -0
- package/dist/clients/error-class.js +23 -0
- package/dist/clients/event-loop-hold.js +274 -0
- package/dist/clients/event-loop-monitor.js +66 -1
- package/dist/clients/extension-log.js +2 -2
- package/dist/clients/feature-hints.js +2 -1
- package/dist/clients/file-time.js +6 -1
- package/dist/clients/file-utils.js +266 -13
- package/dist/clients/finding-delivery-gate.js +55 -13
- package/dist/clients/format-service.js +6 -1
- package/dist/clients/formatters.js +303 -104
- package/dist/clients/freshness-cadence.js +17 -0
- package/dist/clients/generated-artifacts.js +114 -19
- package/dist/clients/generation-guard.js +4 -9
- package/dist/clients/git-guard.js +198 -48
- package/dist/clients/git-tracked-ignore.js +32 -1
- package/dist/clients/go-client.js +39 -0
- package/dist/clients/govulncheck-client.js +5 -1
- package/dist/clients/gradle-ktfmt-style.js +252 -0
- package/dist/clients/gzip-stage-write.js +7 -0
- package/dist/clients/hashline-anchor.js +424 -0
- package/dist/clients/hook-budgets.js +76 -0
- package/dist/clients/host-edit-normalize.js +5 -2
- package/dist/clients/host-ports.js +1 -1
- package/dist/clients/installer/index.js +200 -84
- package/dist/clients/installer/managed-tool-refresh.js +3 -1
- package/dist/clients/instance-reaper.js +108 -243
- package/dist/clients/instance-registry-lock.js +145 -0
- package/dist/clients/instance-registry.js +435 -122
- package/dist/clients/jscpd-client.js +6 -1
- package/dist/clients/language-profile.js +4 -2
- package/dist/clients/language-registry.js +595 -0
- package/dist/clients/latency-logger.js +179 -3
- package/dist/clients/ledger-bounds.js +13 -0
- package/dist/clients/lens-config.js +141 -30
- package/dist/clients/lens-engine.js +30 -37
- package/dist/clients/lens-flag-registry.js +67 -6
- package/dist/clients/lens-map.js +3 -1
- package/dist/clients/log-cleanup.js +2 -2
- package/dist/clients/lsp/aggregation.js +3 -1
- package/dist/clients/lsp/cascade-tier.js +4 -0
- package/dist/clients/lsp/client.js +672 -49
- package/dist/clients/lsp/config.js +504 -156
- package/dist/clients/lsp/diagnostic-binding.js +10 -5
- package/dist/clients/lsp/edits.js +15 -2
- package/dist/clients/lsp/index.js +1179 -247
- package/dist/clients/lsp/jvm-runtime.js +5 -0
- package/dist/clients/lsp/language.js +18 -169
- package/dist/clients/lsp/launch.js +38 -9
- package/dist/clients/lsp/pending-aux-coverage.js +113 -12
- package/dist/clients/lsp/server.js +426 -160
- package/dist/clients/lsp/session-roots.js +192 -0
- package/dist/clients/lsp/spawn-history.js +2 -7
- package/dist/clients/lsp/tsserver-sync.js +9 -2
- package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
- package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
- package/dist/clients/lsp-mutation.js +179 -23
- package/dist/clients/map-with-concurrency.js +36 -0
- package/dist/clients/mcp/analyze.js +45 -10
- package/dist/clients/mcp/session.js +19 -17
- package/dist/clients/memory-sampler.js +28 -1
- package/dist/clients/message-end-attribution.js +30 -0
- package/dist/clients/metrics-history.js +21 -4
- package/dist/clients/middle-man-analysis.js +2 -4
- package/dist/clients/module-report.js +20 -45
- package/dist/clients/mutating-tool.js +651 -0
- package/dist/clients/mutation-attribution.js +368 -0
- package/dist/clients/mutation-bridge.js +240 -0
- package/dist/clients/ndjson-logger.js +247 -20
- package/dist/clients/observed-mutation-sources.js +101 -0
- package/dist/clients/observed-mutation.js +1215 -0
- package/dist/clients/opaque-mutation-scan.js +300 -40
- package/dist/clients/opengrep-config.js +4 -0
- package/dist/clients/package-manager.js +201 -44
- package/dist/clients/partial-edit-apply.js +359 -80
- package/dist/clients/path-attribution-telemetry.js +23 -7
- package/dist/clients/path-keyed-map.js +35 -3
- package/dist/clients/path-utils.js +460 -13
- package/dist/clients/persist-debounce.js +8 -1
- package/dist/clients/php-cs-fixer-config.js +114 -0
- package/dist/clients/pipeline.js +77 -24
- package/dist/clients/probe-home-state.js +228 -0
- package/dist/clients/process-bridge.js +66 -0
- package/dist/clients/process-singletons.js +210 -0
- package/dist/clients/process-snapshot.js +68 -0
- package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
- package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
- package/dist/clients/project-diagnostics/scanner.js +61 -24
- package/dist/clients/project-lens-config.js +393 -122
- package/dist/clients/project-snapshot.js +85 -35
- package/dist/clients/python-environment.js +217 -0
- package/dist/clients/python-provenance.js +639 -0
- package/dist/clients/quiet-window.js +6 -1
- package/dist/clients/read-bridge.js +13 -21
- package/dist/clients/read-expansion.js +9 -46
- package/dist/clients/read-guard-logger.js +39 -6
- package/dist/clients/read-guard-tool-lines.js +364 -212
- package/dist/clients/read-guard.js +95 -7
- package/dist/clients/recent-touches.js +17 -5
- package/dist/clients/resource-sampler.js +195 -96
- package/dist/clients/review-graph/builder.js +752 -132
- package/dist/clients/review-graph/service.js +55 -3
- package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
- package/dist/clients/review-graph/tsconfig-paths.js +5 -1
- package/dist/clients/review-graph/workspace-modules.js +10 -74
- package/dist/clients/review-graph-logger.js +30 -4
- package/dist/clients/runtime-agent-end.js +18 -2
- package/dist/clients/runtime-context.js +49 -16
- package/dist/clients/runtime-coordinator.js +93 -6
- package/dist/clients/runtime-session.js +169 -34
- package/dist/clients/runtime-tool-call.js +183 -35
- package/dist/clients/runtime-tool-result.js +894 -241
- package/dist/clients/runtime-turn.js +1264 -74
- package/dist/clients/rust-client.js +31 -0
- package/dist/clients/safe-spawn.js +191 -28
- package/dist/clients/sanitize.js +7 -1
- package/dist/clients/scratch-tree-policy.js +1 -3
- package/dist/clients/security-scan-client.js +51 -17
- package/dist/clients/session-lifecycle.js +238 -42
- package/dist/clients/session-start-observability.js +79 -0
- package/dist/clients/sessionstart-logger.js +12 -3
- package/dist/clients/sg-runner.js +27 -15
- package/dist/clients/sgconfig.js +6 -1
- package/dist/clients/shared-checkout-guard.js +338 -0
- package/dist/clients/skills-resolver.js +105 -0
- package/dist/clients/smells-rollup.js +2 -2
- package/dist/clients/spawn-output-cap.js +37 -0
- package/dist/clients/spawn-timeout-cooldown.js +15 -1
- package/dist/clients/startup-scan.js +6 -7
- package/dist/clients/startup-timing.js +69 -4
- package/dist/clients/string-utils.js +33 -0
- package/dist/clients/subagent-mode.js +17 -4
- package/dist/clients/test-runner-client.js +586 -118
- package/dist/clients/test-runner-delivery.js +239 -0
- package/dist/clients/tool-definition.js +41 -1
- package/dist/clients/tool-policy.js +92 -22
- package/dist/clients/tree-sitter-cache.js +40 -40
- package/dist/clients/tree-sitter-client.js +97 -53
- package/dist/clients/tree-sitter-logger.js +19 -3
- package/dist/clients/tree-sitter-query-loader.js +81 -1
- package/dist/clients/tree-sitter-shared.js +51 -46
- package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
- package/dist/clients/typos-config.js +5 -0
- package/dist/clients/user-notify.js +6 -2
- package/dist/clients/warm-attach.js +8 -10
- package/dist/clients/widget-state.js +191 -26
- package/dist/clients/word-index-logger.js +15 -3
- package/dist/clients/word-index-store.js +463 -0
- package/dist/clients/word-index.js +815 -125
- package/dist/clients/workspace-topology.js +25 -1
- package/dist/clients/zizmor-config.js +29 -6
- package/dist/index.js +68577 -53950
- package/dist/mcp/analyze-cli.js +3 -1
- package/dist/mcp/build-staleness.js +8 -7
- package/dist/mcp/cli.js +3 -1
- package/dist/mcp/server.js +133 -6
- package/dist/scripts/lib/process-scan.mjs +583 -0
- package/dist/scripts/lib/skills-predicate.mjs +129 -0
- package/dist/tools/effective-config.js +89 -0
- package/dist/tools/lens-diagnostics.js +99 -24
- package/dist/tools/lsp-diagnostics.js +81 -85
- package/dist/tools/lsp-navigation.js +40 -17
- package/dist/tools/shared.js +0 -1
- package/docs/agent-guide.md +68 -3
- package/docs/ast-grep_rules_catalog.md +41 -10
- package/docs/astplayground.md +26 -17
- package/docs/configuration.md +222 -0
- package/docs/dependencies.md +3 -3
- package/docs/features.md +44 -5
- package/docs/language-coverage.md +2 -2
- package/docs/pi-lens-fixer.md +25 -0
- package/docs/pi-lens-investigator.md +25 -0
- package/docs/pi-lens-reviewer.md +27 -0
- package/docs/pi-lens-subagent.md +38 -0
- package/docs/pi-lens-warden.md +55 -0
- package/docs/public-api-stability.md +359 -0
- package/docs/release-qa-baseline.md +182 -0
- package/docs/subagent-compat.md +110 -30
- package/docs/tree-sitter_rules_catalog.md +2 -2
- package/package.json +17 -21
- package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
- package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
- package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
- package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
- package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
- package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
- package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
- package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
- package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
- package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
- package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
- package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
- package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
- package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
- package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
- package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
- package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
- package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
- package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
- package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
- package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
- package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
- package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
- package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
- package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
- package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
- package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
- package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
- package/rules/ast-grep-rules/rules/no-important.yml +21 -0
- package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
- package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
- package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
- package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
- package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
- package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
- package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
- package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
- package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
- package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
- package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
- package/scripts/analyze-pi-lens-logs.mjs +192 -1
- package/scripts/install-selftest.mjs +58 -2
- package/scripts/lib/skills-predicate.mjs +129 -0
- package/scripts/rpc-load-check.mjs +3 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
1
2
|
import * as path from "node:path";
|
|
2
|
-
import {
|
|
3
|
+
import { buildActionableWarningsReport, formatActionableWarningsAdvisory, publishActionableWarningsReport, writeDeferredActionableWarningsReport, } from "./actionable-warnings.js";
|
|
3
4
|
import { logActionableWarningsEvent } from "./actionable-warnings-logger.js";
|
|
4
5
|
import { appendCodeQualityWarningsHistory, buildCodeQualityWarningsReport, formatCodeQualityWarningsAdvisory, writeCodeQualityWarningsReport, } from "./code-quality-warnings.js";
|
|
5
6
|
import { clearGitGuardTestFailure, mergeGitGuardTestFailure, writeGitGuardRecord, } from "./git-guard.js";
|
|
6
7
|
import { cascadeSettleWaitMs } from "./cascade-budget.js";
|
|
7
8
|
import { logCascade } from "./cascade-logger.js";
|
|
8
9
|
import { normalizeMapKey } from "./path-utils.js";
|
|
10
|
+
import { compareOrdinal } from "./string-utils.js";
|
|
9
11
|
import { resolveRunnerPath, toRunnerDisplayPath, } from "./dispatch/runner-context.js";
|
|
10
12
|
import { getKnipIgnorePatterns } from "./file-utils.js";
|
|
11
13
|
import { formatCacheAgeLabel } from "./finding-delivery-gate.js";
|
|
@@ -28,20 +30,251 @@ import { emitLensTurnFindings } from "./lens-events.js";
|
|
|
28
30
|
import { RUNTIME_CONFIG } from "./runtime-config.js";
|
|
29
31
|
import { isSubagentSession } from "./subagent-mode.js";
|
|
30
32
|
import { formatRunDurationMs } from "./run-duration.js";
|
|
33
|
+
import { isExcludedTestTarget, isRunnerErrorResult, RUNNERS, } from "./test-runner-client.js";
|
|
31
34
|
import { MAX_ADVISORY_AFFECTED_FILES, gateFindingsByPathFreshness, snapshotAdvisoryProvenance, } from "./advisory-provenance.js";
|
|
32
|
-
import { sweepInlineBlockerFreshness } from "./blocker-freshness.js";
|
|
35
|
+
import { DEPENDENCY_DRIFT_MAX_DELIVERIES, sweepInlineBlockerFreshness, } from "./blocker-freshness.js";
|
|
33
36
|
import { sweepInlineBlockerPastEof } from "./blocker-past-eof.js";
|
|
34
37
|
// #2001/#2002: collect-later delivery for slow auxiliary LSP servers.
|
|
35
38
|
import { getLSPService } from "./lsp/index.js";
|
|
36
|
-
import { drainPendingAuxiliaryCoverage, isPendingAuxiliaryPastRearmTtl,
|
|
39
|
+
import { drainPendingAuxCapEvictedCount, drainPendingAuxiliaryCoverage, isPendingAuxiliaryPastRearmTtl, rearmPendingAuxiliaryCoverage, MAX_LATE_AUX_REARMS, pendingAuxiliaryCoverageSize, } from "./lsp/pending-aux-coverage.js";
|
|
37
40
|
import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
|
|
41
|
+
import { drainPendingRunnerFindings, dropStaleRunnerFindings, pendingRunnerFindingsSize, } from "./dispatch/pending-runner-findings.js";
|
|
38
42
|
// #1631 review V2: moved to its own leaf module so a low-level store
|
|
39
43
|
// (widget-state.ts) can use the marker without importing this orchestrator —
|
|
40
44
|
// see clients/stale-marker.ts's doc comment.
|
|
41
|
-
import { incrementDegradationCount } from "./degradation-ledger.js";
|
|
42
|
-
import {
|
|
45
|
+
import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
|
|
46
|
+
import { mapWithConcurrency } from "./map-with-concurrency.js";
|
|
47
|
+
import { getAmbientAbortSignal } from "./safe-spawn.js";
|
|
48
|
+
import { emitBounded } from "./bounded-telemetry.js";
|
|
49
|
+
import { degradeDemotedFindingBody, formatDeliveryCapNote, formatRetirementNote, } from "./demoted-finding-render.js";
|
|
43
50
|
import { STALE_LINE_MARKER } from "./stale-marker.js";
|
|
44
|
-
import {
|
|
51
|
+
import { getActiveSessionId } from "./session-lifecycle.js";
|
|
52
|
+
import { drainRenderedDependencyDriftFilePaths, getWidgetBlockingFilesForSweep, incrementWidgetDependencyDriftDelivery, markWidgetFileBlockersStale, recordRunner, retireWidgetDependencyDriftBlockers, } from "./widget-state.js";
|
|
53
|
+
/** Maximum detailed notify-stall coverage-gap rows emitted in one turn. */
|
|
54
|
+
const LATE_AUX_COVERAGE_GAP_DETAIL_CAP_PER_TURN = 20;
|
|
55
|
+
/**
|
|
56
|
+
* #2504 — bounds on the per-turn test-runner fan-out.
|
|
57
|
+
*
|
|
58
|
+
* The reported turn fired 59 `vitest.cmd` spawns at once from a bare
|
|
59
|
+
* `Promise.allSettled(targets.map(...))`. Each spawn carries its OWN 60 s
|
|
60
|
+
* timeout (`test-runner-client.ts`), which bounds one target and says nothing
|
|
61
|
+
* about the batch: 59 of them starved the event loop for the whole turn
|
|
62
|
+
* (`cpuCoverageRatio 0.56`, 20 orphan-backstop escalations inside the storm).
|
|
63
|
+
*
|
|
64
|
+
* Three separate bounds, because they fail differently:
|
|
65
|
+
* - CONCURRENCY caps how much CPU the batch can hold at one instant;
|
|
66
|
+
* - TARGET COUNT caps how much work one turn may enqueue at all;
|
|
67
|
+
* - the WALL BUDGET caps how long the batch may keep spawning, and is raced
|
|
68
|
+
* against the ambient abort signal so a cancelled turn stops dispatching
|
|
69
|
+
* immediately rather than at the next natural boundary.
|
|
70
|
+
*
|
|
71
|
+
* #2522: the batch budget shipped at 90 s in #2509 — well over the turn
|
|
72
|
+
* itself, so a slow/hung batch could still hold up the agent for most of a
|
|
73
|
+
* turn before the bound even engaged. Reconciled down to 20 s (one constant,
|
|
74
|
+
* not a second budget alongside it): well under the turn, and still an order
|
|
75
|
+
* of magnitude below the PER-TARGET 60 s cap in `test-runner-client.ts`, so
|
|
76
|
+
* the batch bound is the one that actually fires first for a stuck target.
|
|
77
|
+
*
|
|
78
|
+
* #2522 review round 2: lowering the budget on its own made things WORSE,
|
|
79
|
+
* because the "deferred to the next turn" this comment used to claim did not
|
|
80
|
+
* exist. `runTestTargetsBounded` returned a skipped COUNT and threw the
|
|
81
|
+
* identity of the unrun targets away, so a target slower than the budget
|
|
82
|
+
* produced no result, no cache record and no output — every turn, forever —
|
|
83
|
+
* while a PARTIAL batch fell through to the clean branch and was reported as
|
|
84
|
+
* "all tests passed". The deferral is now a real mechanism:
|
|
85
|
+
*
|
|
86
|
+
* - the batch owns an `AbortController` that is handed to each target's spawn
|
|
87
|
+
* (`TestRunRequest.signal` → `safeSpawnAsync`), so reaching the bound KILLS
|
|
88
|
+
* what is in flight instead of leaving it to burn its own 60 s timeout and
|
|
89
|
+
* then mutate a results array the caller has already consumed;
|
|
90
|
+
* - the killed set plus the never-dispatched set is returned BY IDENTITY as
|
|
91
|
+
* `deferred`, persisted on the `test-runner-findings` record as
|
|
92
|
+
* `deferredTargets`, and dispatched FIRST on the next turn — ahead of
|
|
93
|
+
* failed-first/related/self — under the same `TEST_RUNNER_MAX_TARGETS` cap;
|
|
94
|
+
* - a batch whose `deferred` set is non-empty can never take the clean
|
|
95
|
+
* branch, so an unfinished run is never reported as a green one and never
|
|
96
|
+
* relaxes the `--lens-guard` commit gate.
|
|
97
|
+
*/
|
|
98
|
+
export const TEST_RUNNER_BATCH_CONCURRENCY = 4;
|
|
99
|
+
export const TEST_RUNNER_MAX_TARGETS = 12;
|
|
100
|
+
export const TEST_RUNNER_BATCH_BUDGET_MS = 20_000;
|
|
101
|
+
/**
|
|
102
|
+
* How many consecutive turn-end batches a target may be cut out of before it
|
|
103
|
+
* is retired from turn-end selection altogether.
|
|
104
|
+
*
|
|
105
|
+
* Without this the deferral is a LIVELOCK, and not hypothetically: this
|
|
106
|
+
* repo's own `tests/index-integration.test.ts` — which turn-end resolves
|
|
107
|
+
* through the `related` strategy whenever `clients/runtime-turn.ts` is
|
|
108
|
+
* edited — takes 26.4 s, MORE than the entire 20 s batch budget on its own.
|
|
109
|
+
* Deferring it puts it FIRST in the next batch, where it is cut again at
|
|
110
|
+
* 20 s, and again, every turn, forever. A target that cannot fit the budget
|
|
111
|
+
* is not a scheduling problem retrying can solve; it is reported once per
|
|
112
|
+
* occurrence and dropped, so the turn stops paying 20 s for it.
|
|
113
|
+
*/
|
|
114
|
+
export const TEST_RUNNER_MAX_DEFERRALS = 2;
|
|
115
|
+
/**
|
|
116
|
+
* Ceiling on how many entries either persisted target list may carry.
|
|
117
|
+
*
|
|
118
|
+
* #2522 review round 4, I1: a write may no longer destroy another session's
|
|
119
|
+
* entries, so nothing in the write path prunes them any more — each session
|
|
120
|
+
* that ever cut or retired a target in this project leaves its rows behind, on
|
|
121
|
+
* a record read at every single turn_end. The bound is applied at the one
|
|
122
|
+
* writer, on the whole list, and sheds FOREIGN rows first (the writer orders
|
|
123
|
+
* this session's entries last), so it can never evict the entries this turn
|
|
124
|
+
* depends on. Generous on purpose: it is a backstop against unbounded growth,
|
|
125
|
+
* not a scheduling policy.
|
|
126
|
+
*/
|
|
127
|
+
export const TEST_RUNNER_MAX_PERSISTED_TARGETS = 64;
|
|
128
|
+
/**
|
|
129
|
+
* Union two deferral sets by target identity, keeping the HIGHER attempt count
|
|
130
|
+
* (#2522 review round 3, F3). Two overlapping batches can both be cut on the
|
|
131
|
+
* same target; taking the lower count would let a target trade an attempt for
|
|
132
|
+
* every overlap and never converge on `TEST_RUNNER_MAX_DEFERRALS`.
|
|
133
|
+
*
|
|
134
|
+
* Identity is (session, path), not path alone (#2522 review round 4, I1): two
|
|
135
|
+
* sessions can each owe a run of the same file, and collapsing those into one
|
|
136
|
+
* row makes the surviving row's `sessionId` decide whose deferral is honoured
|
|
137
|
+
* and whose is silently dropped. The path half is keyed through
|
|
138
|
+
* `normalizeMapKey` so `/`- and `\`-separated spellings are one entry
|
|
139
|
+
* (AGENTS.md cross-form-path screen).
|
|
140
|
+
*/
|
|
141
|
+
function deferralEntryKey(entry) {
|
|
142
|
+
return `${entry.sessionId ?? ""}\u0000${normalizeMapKey(path.resolve(entry.testFile))}`;
|
|
143
|
+
}
|
|
144
|
+
function mergeDeferredTargets(existing, incoming) {
|
|
145
|
+
const byKey = new Map();
|
|
146
|
+
for (const entry of [...existing, ...incoming]) {
|
|
147
|
+
const key = deferralEntryKey(entry);
|
|
148
|
+
const prior = byKey.get(key);
|
|
149
|
+
if (prior && (prior.attempts ?? 0) >= (entry.attempts ?? 0))
|
|
150
|
+
continue;
|
|
151
|
+
byKey.set(key, entry);
|
|
152
|
+
}
|
|
153
|
+
return [...byKey.values()];
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Run `run` over `targets` with a concurrency cap, a batch-wide wall budget
|
|
157
|
+
* and an abort-signal race. Reuses `mapWithConcurrency` (the repo's existing
|
|
158
|
+
* worker-pool shape) rather than hand-rolling a second pool.
|
|
159
|
+
*
|
|
160
|
+
* Both bounds are enforced twice on purpose: cooperatively, before each
|
|
161
|
+
* dispatch, so no NEW work starts past the bound; and as a real race against
|
|
162
|
+
* the pool, so an in-flight target cannot hold the batch past its budget.
|
|
163
|
+
* Whatever has settled by then is returned — a partial batch of real results
|
|
164
|
+
* is strictly better than none, and the caller reports the shortfall.
|
|
165
|
+
*/
|
|
166
|
+
export async function runTestTargetsBounded(args) {
|
|
167
|
+
const results = [];
|
|
168
|
+
if (args.targets.length === 0)
|
|
169
|
+
return { results, deferred: [] };
|
|
170
|
+
let stopReason;
|
|
171
|
+
// One controller for the whole batch. Aborting it tree-kills every child the
|
|
172
|
+
// runner has in flight (`safeSpawnAsync` honours the signal it is handed),
|
|
173
|
+
// which is what turns "the bound fired" into "the work actually stopped".
|
|
174
|
+
const batchAbort = new AbortController();
|
|
175
|
+
// Latched at the moment this call returns. Nothing may push into `results`
|
|
176
|
+
// after that: the caller has already consumed the array, and a late push
|
|
177
|
+
// would silently change a batch it has finished reasoning about.
|
|
178
|
+
let closed = false;
|
|
179
|
+
/**
|
|
180
|
+
* #2522 review round 4, I4: "was this target CUT, or did it FINISH?" is a
|
|
181
|
+
* semantic question, and this set is the answer — stamped by the `.then`
|
|
182
|
+
* attached to `run`'s promise AT DISPATCH, which is that promise's FIRST
|
|
183
|
+
* continuation. Nothing but a microtask can run between the promise settling
|
|
184
|
+
* and this stamp, and every path that closes the batch (`setTimeout`, the
|
|
185
|
+
* ambient signal's `abort` event, the pool settling after every mapper has
|
|
186
|
+
* returned) reaches `finish()` from a macrotask or later. So `closed` read
|
|
187
|
+
* here is exactly "the batch was already cut when this value existed".
|
|
188
|
+
*
|
|
189
|
+
* Round 3 read the same flag from the mapper's own `await` continuation,
|
|
190
|
+
* which is several continuations downstream of the settle — a macrotask CAN
|
|
191
|
+
* interleave there — and papered over the resulting misclassification with a
|
|
192
|
+
* `setImmediate` hop plus a `latchedOut` parking array. Both are gone: the
|
|
193
|
+
* hop made the answer depend on queue ordering, which is the thing that kept
|
|
194
|
+
* producing findings (a spawn aborted BEFORE it started resolves
|
|
195
|
+
* synchronously, so the hop folded it back in as a completed run — round 4
|
|
196
|
+
* P3c).
|
|
197
|
+
*/
|
|
198
|
+
const settledBeforeClose = new Set();
|
|
199
|
+
const budgetMs = Math.max(0, args.budgetMs);
|
|
200
|
+
const deadline = Date.now() + budgetMs;
|
|
201
|
+
const shouldStop = () => {
|
|
202
|
+
if (stopReason !== undefined)
|
|
203
|
+
return true;
|
|
204
|
+
if (args.signal?.aborted) {
|
|
205
|
+
stopReason = "abort";
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
if (Date.now() >= deadline) {
|
|
209
|
+
stopReason = "budget";
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
};
|
|
214
|
+
const pool = mapWithConcurrency(args.targets.map((target, index) => ({ target, index })), Math.max(1, args.concurrency), async ({ target, index }) => {
|
|
215
|
+
if (shouldStop())
|
|
216
|
+
return;
|
|
217
|
+
const running = args.run(target, batchAbort.signal);
|
|
218
|
+
// Attached HERE, at dispatch, so it is the promise's first
|
|
219
|
+
// continuation — see `settledBeforeClose`. A value that only exists
|
|
220
|
+
// because the batch was cut (a killed spawn's runner error, or
|
|
221
|
+
// `safeSpawnAsync`'s synchronous "Spawn aborted before start") settles
|
|
222
|
+
// strictly after the close and is therefore never recorded, so the
|
|
223
|
+
// caller defers that target instead of reading it as a completed run.
|
|
224
|
+
const stamp = running.then((value) => {
|
|
225
|
+
if (closed)
|
|
226
|
+
return;
|
|
227
|
+
settledBeforeClose.add(index);
|
|
228
|
+
results.push({ status: "fulfilled", value });
|
|
229
|
+
}, (reason) => {
|
|
230
|
+
if (closed)
|
|
231
|
+
return;
|
|
232
|
+
settledBeforeClose.add(index);
|
|
233
|
+
results.push({ status: "rejected", reason });
|
|
234
|
+
});
|
|
235
|
+
// The stamp swallows both settlements, so this only keeps the pool's
|
|
236
|
+
// concurrency slot held for as long as the work actually runs.
|
|
237
|
+
await stamp;
|
|
238
|
+
});
|
|
239
|
+
// The pool's mapper swallows every throw from an async `run`; a `run` that throws
|
|
240
|
+
// SYNCHRONOUSLY still rejects the pool and closes the whole batch (everything defers,
|
|
241
|
+
// nothing publishes clean). Production runners are async, so this is fail-safe, not reachable.
|
|
242
|
+
void pool.catch(() => { });
|
|
243
|
+
await new Promise((resolve) => {
|
|
244
|
+
let settled = false;
|
|
245
|
+
const finish = (reason) => {
|
|
246
|
+
if (settled)
|
|
247
|
+
return;
|
|
248
|
+
settled = true;
|
|
249
|
+
if (reason !== undefined)
|
|
250
|
+
stopReason ??= reason;
|
|
251
|
+
// Latch BEFORE resolving, so no continuation scheduled by the kill
|
|
252
|
+
// below can slip a result in behind the caller's back.
|
|
253
|
+
closed = true;
|
|
254
|
+
// Kill whatever is still in flight. Without this the pool ran on past
|
|
255
|
+
// the bound: a stuck vitest spawn kept a core busy to its own 60s
|
|
256
|
+
// timeout, long after the turn had moved on.
|
|
257
|
+
batchAbort.abort();
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
args.signal?.removeEventListener("abort", onAbort);
|
|
260
|
+
resolve();
|
|
261
|
+
};
|
|
262
|
+
const onAbort = () => finish("abort");
|
|
263
|
+
const timer = setTimeout(() => finish("budget"), budgetMs);
|
|
264
|
+
// A pending batch timer must never hold the process open.
|
|
265
|
+
timer.unref?.();
|
|
266
|
+
if (args.signal?.aborted)
|
|
267
|
+
finish("abort");
|
|
268
|
+
else
|
|
269
|
+
args.signal?.addEventListener("abort", onAbort, { once: true });
|
|
270
|
+
void pool.then(() => finish(), () => finish());
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
results,
|
|
274
|
+
deferred: args.targets.filter((_, index) => !settledBeforeClose.has(index)),
|
|
275
|
+
stopReason,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
45
278
|
/**
|
|
46
279
|
* #1617: turn_end reads gitleaks/govulncheck/trivy straight from their
|
|
47
280
|
* session-scan caches and formats them into advisory/blocker text — a
|
|
@@ -152,6 +385,9 @@ function scheduleLSPIdleReset(resetFn, delayMs, options = {}) {
|
|
|
152
385
|
if (options.isCurrentSession && !options.isCurrentSession()) {
|
|
153
386
|
return;
|
|
154
387
|
}
|
|
388
|
+
if (options.isPrimarySession && !options.isPrimarySession()) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
155
391
|
resetFn();
|
|
156
392
|
}
|
|
157
393
|
catch (err) {
|
|
@@ -233,7 +469,7 @@ function capTurnEndMessage(content) {
|
|
|
233
469
|
return out;
|
|
234
470
|
}
|
|
235
471
|
export async function handleTurnEnd(deps) {
|
|
236
|
-
const { ctxCwd, getFlag, dbg, runtime, cacheManager, knipClient, deadCodeClients, depChecker, testRunnerClient, owner, resetLSPService, resetFormatService, } = deps;
|
|
472
|
+
const { ctxCwd, getFlag, dbg, runtime, cacheManager, knipClient, deadCodeClients, depChecker, testRunnerClient, sessionId, owner, resetLSPService, resetFormatService, } = deps;
|
|
237
473
|
// #449 slice 1: piggyback the instance-registry heartbeat on this existing
|
|
238
474
|
// per-turn touchpoint rather than adding a new timer/interval. Cheap (reads
|
|
239
475
|
// process.memoryUsage().rss, one read-modify-write of instances.json) and
|
|
@@ -255,13 +491,27 @@ export async function handleTurnEnd(deps) {
|
|
|
255
491
|
let turnState = cacheManager.readTurnState(cwd);
|
|
256
492
|
// A live foreign writer owns this worklist. Do not clear or consume another
|
|
257
493
|
// pi/MCP session's files; a dead/aged owner is safely evicted instead.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
494
|
+
// #2504: `sessionStartedAt` dates the persisted worklist against THIS
|
|
495
|
+
// session. Without it an ownerless turn-state.json — the resting shape
|
|
496
|
+
// before #2504 — read back as "owned" no matter how old it was.
|
|
497
|
+
const currentOwner = {
|
|
498
|
+
...(owner ?? {
|
|
499
|
+
kind: "pi",
|
|
500
|
+
id: runtime.telemetrySessionId,
|
|
501
|
+
pid: process.pid,
|
|
502
|
+
lastSeen: new Date().toISOString(),
|
|
503
|
+
}),
|
|
504
|
+
sessionStartedAt: owner?.sessionStartedAt ?? runtime.sessionStartedAt,
|
|
263
505
|
};
|
|
264
506
|
const access = cacheManager.getTurnStateAccess(cwd, currentOwner);
|
|
507
|
+
// Captured BEFORE the eviction below rewrites the file: the owner the gate
|
|
508
|
+
// actually judged. This pair is what would have settled #2504 from the
|
|
509
|
+
// debug log alone.
|
|
510
|
+
const gateOwnerLabel = turnState.owner
|
|
511
|
+
? `${turnState.owner.kind}:${turnState.owner.id}`
|
|
512
|
+
: turnState.sessionId
|
|
513
|
+
? `legacy:${turnState.sessionId}`
|
|
514
|
+
: "none";
|
|
265
515
|
const sameProcessPiSessionHandoff = access === "foreign-live" &&
|
|
266
516
|
currentOwner.kind === "pi" &&
|
|
267
517
|
turnState.owner?.kind === "pi" &&
|
|
@@ -278,6 +528,49 @@ export async function handleTurnEnd(deps) {
|
|
|
278
528
|
turnState = cacheManager.readTurnState(cwd);
|
|
279
529
|
}
|
|
280
530
|
const files = Object.keys(turnState.files);
|
|
531
|
+
/**
|
|
532
|
+
* #2275: widget-footer sibling of #1950's inline-blocker cap, for the
|
|
533
|
+
* widget store's OWN dependency-drift demotion
|
|
534
|
+
* (`markWidgetFileBlockersStale`, driven by the freshness sweep further
|
|
535
|
+
* down this function) — a completely separate store from
|
|
536
|
+
* `RuntimeCoordinator`'s inline-blocker map, so it needed its own
|
|
537
|
+
* delivery count (`WidgetDiagnostic.staleDeliveryCount`) rather than
|
|
538
|
+
* inheriting one.
|
|
539
|
+
*
|
|
540
|
+
* Review F1: the population is what the footer RENDERED since the last
|
|
541
|
+
* turn end, drained here — not every file that merely holds a demoted
|
|
542
|
+
* row. The footer draws one record per pass (`withBlocking[0]`, its top
|
|
543
|
+
* five entries) and may not be drawn at all, so a per-turn walk of the
|
|
544
|
+
* whole store charged deliveries the agent never received and retired a
|
|
545
|
+
* delivery early. This is the widget-surface analogue of the inline
|
|
546
|
+
* loop's own `pendingDependencyDriftDeliveries` deferral below: both
|
|
547
|
+
* commit a delivery only once the surface has actually served it. Every
|
|
548
|
+
* `deliveryCount` reported to the ledger is therefore a count of RENDERS.
|
|
549
|
+
*
|
|
550
|
+
* Fix-round 3 (#2275 review F1): this drain/charge MUST run before the
|
|
551
|
+
* `files.length === 0` early return below — a read-only turn (no
|
|
552
|
+
* modified files) still repaints the footer and can draw a demoted row,
|
|
553
|
+
* so a cap that only charged deliveries below the early return silently
|
|
554
|
+
* starved on quiet turns: the footer re-rendered the same demoted row
|
|
555
|
+
* every turn while the delivery count never advanced. The drain is a
|
|
556
|
+
* Set.take() plus one map lookup per drained file — cheap enough to run
|
|
557
|
+
* unconditionally on every turn end.
|
|
558
|
+
*/
|
|
559
|
+
let widgetDemotedFindingsRetired = 0;
|
|
560
|
+
for (const wPath of drainRenderedDependencyDriftFilePaths()) {
|
|
561
|
+
const deliveryCount = incrementWidgetDependencyDriftDelivery(wPath);
|
|
562
|
+
if (deliveryCount >= DEPENDENCY_DRIFT_MAX_DELIVERIES) {
|
|
563
|
+
const capRetired = retireWidgetDependencyDriftBlockers(wPath);
|
|
564
|
+
if (capRetired) {
|
|
565
|
+
widgetDemotedFindingsRetired += 1;
|
|
566
|
+
incrementDegradationCount({
|
|
567
|
+
kind: "demoted-finding-retired",
|
|
568
|
+
subject: `widget-blocker:${toRunnerDisplayPath(cwd, wPath)}`,
|
|
569
|
+
reason: `capped after ${deliveryCount} deliveries with no re-run; hidden from the pi-lens footer, still listed by lens_diagnostics mode=all — re-run can still confirm`,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
281
574
|
// R1 (#1443 follow-up): a read-only turn (no files touched) must not take
|
|
282
575
|
// the fast idle-reset path while a carried cascade run — or one still
|
|
283
576
|
// settling — is waiting for its delivery opportunity. Falling through to
|
|
@@ -286,6 +579,11 @@ export async function handleTurnEnd(deps) {
|
|
|
286
579
|
// instead of dying unrendered. `hasCascadeRuns()` is a cheap peek (no
|
|
287
580
|
// pending work almost every turn), so the common read-only turn still
|
|
288
581
|
// takes the early return below.
|
|
582
|
+
// A foreign live owner must not deliver another session's pending findings.
|
|
583
|
+
// A no-file turn falls through when this process has pending runner work so
|
|
584
|
+
// the ordinary freshness gate and delivery cache can run. Max-cycle cleanup
|
|
585
|
+
// below intentionally remains a terminal reset; its pending work stays in
|
|
586
|
+
// the bounded handoff store for the next eligible turn.
|
|
289
587
|
if (files.length === 0 && !runtime.hasCascadeRuns()) {
|
|
290
588
|
// A genuinely clean session must invalidate the persisted guard record.
|
|
291
589
|
// Blocker records are retained only while the runtime still reports one.
|
|
@@ -310,11 +608,20 @@ export async function handleTurnEnd(deps) {
|
|
|
310
608
|
const sessionGeneration = runtime.sessionGeneration;
|
|
311
609
|
scheduleLSPIdleReset(resetLSPService, idleResetMs, {
|
|
312
610
|
isCurrentSession: () => runtime.isCurrentSession(sessionGeneration),
|
|
611
|
+
// #2157 fix round 2: a secondary (subagent) evaluation's own timer
|
|
612
|
+
// must not release the primary's shared fleet — see the option's
|
|
613
|
+
// doc comment on `scheduleLSPIdleReset`.
|
|
614
|
+
isPrimarySession: () => {
|
|
615
|
+
const activePrimarySessionId = getActiveSessionId();
|
|
616
|
+
return (activePrimarySessionId === undefined ||
|
|
617
|
+
activePrimarySessionId === runtime.telemetrySessionId);
|
|
618
|
+
},
|
|
313
619
|
onError: (err) => dbg(`lsp idle reset failed: ${err}`),
|
|
314
620
|
});
|
|
315
621
|
}
|
|
316
622
|
resetFormatService();
|
|
317
|
-
|
|
623
|
+
if (pendingRunnerFindingsSize() === 0)
|
|
624
|
+
return;
|
|
318
625
|
}
|
|
319
626
|
// Cancel any pending idle reset since we're actively working. #1618: also
|
|
320
627
|
// checks `pendingSweepRearm` — a timer deferred behind an in-flight
|
|
@@ -323,11 +630,11 @@ export async function handleTurnEnd(deps) {
|
|
|
323
630
|
// read "nothing pending" and skip the cancel while a rearm was still
|
|
324
631
|
// queued to fire the instant the sweep released its hold — resurrecting
|
|
325
632
|
// idle reset on a session that had since gone back to active editing.
|
|
326
|
-
if (lspIdleResetTimeout || pendingSweepRearm) {
|
|
633
|
+
if (files.length > 0 && (lspIdleResetTimeout || pendingSweepRearm)) {
|
|
327
634
|
cancelLSPIdleReset();
|
|
328
635
|
dbg("turn_end: cancelled pending LSP idle reset (active editing)");
|
|
329
636
|
}
|
|
330
|
-
dbg(`turn_end: ${files.length} file(s) modified, cycles: ${turnState.turnCycles}/${turnState.maxCycles}`);
|
|
637
|
+
dbg(`turn_end: ${files.length} file(s) modified, cycles: ${turnState.turnCycles}/${turnState.maxCycles}, access: ${access}, owner: ${gateOwnerLabel}`);
|
|
331
638
|
if (cacheManager.isMaxCyclesExceeded(cwd)) {
|
|
332
639
|
dbg("turn_end: max cycles exceeded, clearing state and forcing through");
|
|
333
640
|
cacheManager.clearTurnState(cwd, currentOwner);
|
|
@@ -404,9 +711,20 @@ export async function handleTurnEnd(deps) {
|
|
|
404
711
|
// Re-surface inline blockers from this turn that the agent didn't fix.
|
|
405
712
|
// These were shown inline during write/edit but the agent moved on without resolving them.
|
|
406
713
|
const unresolvedBlockers = runtime.getInlineBlockersSnapshot();
|
|
407
|
-
/** #1944:
|
|
714
|
+
/** #1944/#1950: demotions retired after their delivery limit. */
|
|
408
715
|
let demotedFindingsRetired = 0;
|
|
409
|
-
|
|
716
|
+
/**
|
|
717
|
+
* #1950 fix-round F1: dependency-drift delivery-count commits, deferred
|
|
718
|
+
* until this turn's content is confirmed NOT suppressed by the
|
|
719
|
+
* `turn-end-findings-last` signature dedupe further down. That dedupe
|
|
720
|
+
* silences a turn whose rendered content is byte-identical to the last
|
|
721
|
+
* one actually delivered — the agent never sees a suppressed turn, so
|
|
722
|
+
* committing the counter for it would count a delivery that didn't
|
|
723
|
+
* happen. Each entry here is invoked only from the "not suppressed"
|
|
724
|
+
* branch below.
|
|
725
|
+
*/
|
|
726
|
+
const pendingDependencyDriftDeliveries = [];
|
|
727
|
+
for (const { filePath: bPath, summary, stale, staleReason, } of unresolvedBlockers) {
|
|
410
728
|
const displayPath = toRunnerDisplayPath(cwd, bPath);
|
|
411
729
|
if (stale) {
|
|
412
730
|
// #1631: demoted — out of the authoritative blocker channel and into the
|
|
@@ -423,6 +741,7 @@ export async function handleTurnEnd(deps) {
|
|
|
423
741
|
const deadLines = blockerPastEof.deadLinesByPath.get(bPath) ?? [];
|
|
424
742
|
const degraded = degradeDemotedFindingBody(summary, { deadLines });
|
|
425
743
|
const retired = runtime.retireDemotedPastEofBlocker(bPath, deadLines);
|
|
744
|
+
let retirementNote;
|
|
426
745
|
if (retired) {
|
|
427
746
|
demotedFindingsRetired += 1;
|
|
428
747
|
// Bounded by the ledger's own per-kind/subject tally, and the subject
|
|
@@ -432,10 +751,43 @@ export async function handleTurnEnd(deps) {
|
|
|
432
751
|
subject: `inline-blocker:${displayPath}`,
|
|
433
752
|
reason: `file shrank past cited line(s) ${deadLines.join(", ")}; retired after one degraded delivery`,
|
|
434
753
|
});
|
|
754
|
+
retirementNote = formatRetirementNote(deadLines);
|
|
755
|
+
}
|
|
756
|
+
else if (staleReason === "dependency-drift") {
|
|
757
|
+
// #1950: a dependency-drift demotion is recoverable (its coordinates
|
|
758
|
+
// are still in bounds), so it does NOT retire after one delivery like
|
|
759
|
+
// the past-EOF case above — but nothing capped how many times the
|
|
760
|
+
// SAME demoted-but-unconfirmed record re-serves, and incident data
|
|
761
|
+
// showed repeat deliveries carrying near-zero information after the
|
|
762
|
+
// first. Cap it at DEPENDENCY_DRIFT_MAX_DELIVERIES instead.
|
|
763
|
+
//
|
|
764
|
+
// The count driving THIS render is a peek (fix-round F1): the actual
|
|
765
|
+
// increment is deferred to `pendingDependencyDriftDeliveries` below,
|
|
766
|
+
// committed only once this turn's content is known to reach the
|
|
767
|
+
// agent, so a suppressed turn's tentative render never advances the
|
|
768
|
+
// stored count.
|
|
769
|
+
const tentativeCount = runtime.peekInlineBlockerStaleDeliveryCount(bPath) + 1;
|
|
770
|
+
if (tentativeCount >= DEPENDENCY_DRIFT_MAX_DELIVERIES) {
|
|
771
|
+
retirementNote = formatDeliveryCapNote(tentativeCount);
|
|
772
|
+
}
|
|
773
|
+
pendingDependencyDriftDeliveries.push(() => {
|
|
774
|
+
const deliveryCount = runtime.incrementInlineBlockerStaleDelivery(bPath);
|
|
775
|
+
if (deliveryCount >= DEPENDENCY_DRIFT_MAX_DELIVERIES) {
|
|
776
|
+
const capRetired = runtime.retireDemotedDependencyDriftBlocker(bPath);
|
|
777
|
+
if (capRetired) {
|
|
778
|
+
demotedFindingsRetired += 1;
|
|
779
|
+
incrementDegradationCount({
|
|
780
|
+
kind: "demoted-finding-retired",
|
|
781
|
+
subject: `inline-blocker:${displayPath}`,
|
|
782
|
+
reason: `capped after ${deliveryCount} deliveries with no re-run; re-run can still confirm`,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
});
|
|
435
787
|
}
|
|
436
788
|
// @delivery-surface: runtime-turn:unresolved-inline-blocker
|
|
437
789
|
advisoryParts.push(`${STALE_LINE_MARKER} ${displayPath}:\n${degraded.body}` +
|
|
438
|
-
(
|
|
790
|
+
(retirementNote ? `\n${retirementNote}` : ""));
|
|
439
791
|
}
|
|
440
792
|
else {
|
|
441
793
|
// @delivery-surface: runtime-turn:unresolved-inline-blocker
|
|
@@ -1392,10 +1744,12 @@ export async function handleTurnEnd(deps) {
|
|
|
1392
1744
|
metadata: madgeStats ?? { skipped: true },
|
|
1393
1745
|
});
|
|
1394
1746
|
// --- Test runner: fire once per turn after all edits are done ---
|
|
1395
|
-
// Runs for each unique test target across modified files; results
|
|
1396
|
-
//
|
|
1747
|
+
// Runs for each unique test target across modified files; results remain in
|
|
1748
|
+
// the pull-diagnostics cache and are delivered after the agent settles.
|
|
1397
1749
|
if (!getFlag("no-tests") && files.length > 0) {
|
|
1398
1750
|
const seen = new Set();
|
|
1751
|
+
// `strategy` is widened by one value the resolver itself cannot produce:
|
|
1752
|
+
// a target carried over from the previous turn's cut batch (#2522 R2 F1).
|
|
1399
1753
|
const targets = [];
|
|
1400
1754
|
// #628: also target the test companions of this turn's cascade neighbors
|
|
1401
1755
|
// (files that import an edited file) — a neighbor's own tests can break
|
|
@@ -1426,10 +1780,266 @@ export async function handleTurnEnd(deps) {
|
|
|
1426
1780
|
candidates.push({ display: neighbor.filePath, abs, isNeighbor: true });
|
|
1427
1781
|
}
|
|
1428
1782
|
}
|
|
1783
|
+
// #2522 review round 2, F1: the previous turn's unfinished targets go
|
|
1784
|
+
// FIRST — ahead of failed-first/related/self — under the same
|
|
1785
|
+
// TEST_RUNNER_MAX_TARGETS cap, so a batch that keeps getting cut makes
|
|
1786
|
+
// progress instead of re-running whatever this turn happened to touch
|
|
1787
|
+
// and dropping the remainder again. The list is consumed here (written
|
|
1788
|
+
// back empty below) so a target can never be carried forever.
|
|
1789
|
+
const priorTestCache = cacheManager.readCache("test-runner-findings", cwd)?.data;
|
|
1790
|
+
const carriedDeferred = priorTestCache?.deferredTargets ?? [];
|
|
1791
|
+
// #2522 review round 3, F5: a deferral list belongs to the session that
|
|
1792
|
+
// cut it. `runtime.telemetrySessionId` is the same identity the batch
|
|
1793
|
+
// stamps its own entries with below (`firedSessionId`), so this is a
|
|
1794
|
+
// single comparison against one source of truth.
|
|
1795
|
+
const turnSessionId = sessionId ?? runtime.telemetrySessionId;
|
|
1796
|
+
const isThisSession = (entry) => entry.sessionId === turnSessionId;
|
|
1797
|
+
const entryPathKey = (entry) => normalizeMapKey(path.resolve(cwd, entry.testFile));
|
|
1798
|
+
/**
|
|
1799
|
+
* #2522 review round 3, F1: the retirement, carried forward.
|
|
1800
|
+
*
|
|
1801
|
+
* Round 2 announced the retirement and dropped it on the floor, so the
|
|
1802
|
+
* candidate loop below re-resolved the very same file through
|
|
1803
|
+
* `related`/`self` in the SAME turn and ran it anyway with a fresh
|
|
1804
|
+
* (undefined) attempt count. Steady state was a 3-turn cycle that spawned
|
|
1805
|
+
* and cut the slow suite on every turn. The retired set is persisted on
|
|
1806
|
+
* the record instead.
|
|
1807
|
+
*
|
|
1808
|
+
* Round 4, I1: this is a SELECTION filter and nothing more. Round 3 also
|
|
1809
|
+
* wrote the filtered list straight back, so a turn taken by the other
|
|
1810
|
+
* entry point into the same project — `clients/mcp/session.ts` passes no
|
|
1811
|
+
* `sessionId` and falls back to `telemetrySessionId`, while `index.ts`
|
|
1812
|
+
* passes pi's stable id — erased the entries belonging to the other
|
|
1813
|
+
* route. Both lists survive every write now; see `writeTestFindings`.
|
|
1814
|
+
*/
|
|
1815
|
+
const retiredCarry = (priorTestCache?.retiredTargets ?? []).filter(isThisSession);
|
|
1816
|
+
const retiredKeys = new Set(retiredCarry.map(entryPathKey));
|
|
1817
|
+
/**
|
|
1818
|
+
* Deferral entries of THIS session that this turn has settled: dispatched
|
|
1819
|
+
* into `targets`, retired at the cap, or dropped as unrunnable. Only
|
|
1820
|
+
* these may leave the persisted list, and only when this turn's own write
|
|
1821
|
+
* is not re-asserting them (a cut target comes straight back with
|
|
1822
|
+
* `attempts + 1`).
|
|
1823
|
+
*/
|
|
1824
|
+
const resolvedDeferralKeys = new Set();
|
|
1825
|
+
/**
|
|
1826
|
+
* #2522 review round 4, I5: entries this turn could not fit under
|
|
1827
|
+
* `TEST_RUNNER_MAX_TARGETS`. They were not run, so they keep their attempt
|
|
1828
|
+
* count and stay on the list — round 3 folded the over-cap case in with
|
|
1829
|
+
* "no longer resolves" and dropped them outright, so a carry larger than
|
|
1830
|
+
* the cap lost its tail every turn under a dbg line that said the files
|
|
1831
|
+
* were gone.
|
|
1832
|
+
*/
|
|
1833
|
+
const heldDeferred = [];
|
|
1834
|
+
/**
|
|
1835
|
+
* The ONE writer of `test-runner-findings` in this block, so the rules
|
|
1836
|
+
* about what survives a write are stated once instead of at six call sites
|
|
1837
|
+
* that a seventh would silently not join (#2522 review round 3, F1; same
|
|
1838
|
+
* net-count rule as `retireTestFindings` in `runtime-context.ts`).
|
|
1839
|
+
*
|
|
1840
|
+
* Three rules, all of them round-4 invariants:
|
|
1841
|
+
* - I1: the LIVE record is re-read here and merged into, never replaced.
|
|
1842
|
+
* A row belonging to another session is untouchable — this turn may
|
|
1843
|
+
* ignore it, never delete it.
|
|
1844
|
+
* - I2: every write merges. A batch that publishes clean, a batch that
|
|
1845
|
+
* publishes failures and a superseded batch all go through this, so
|
|
1846
|
+
* none of them can overwrite a concurrent batch's cut set. Removal is
|
|
1847
|
+
* narrow by construction: an entry leaves only if it is this session's,
|
|
1848
|
+
* this turn settled it, and this write is not re-asserting it.
|
|
1849
|
+
* - the persisted lists are bounded (`TEST_RUNNER_MAX_PERSISTED_TARGETS`)
|
|
1850
|
+
* with this session's rows ordered LAST, so the bound sheds foreign
|
|
1851
|
+
* rows first and can never evict what this turn just recorded.
|
|
1852
|
+
*/
|
|
1853
|
+
const boundPersisted = (entries, label) => {
|
|
1854
|
+
const ordered = [
|
|
1855
|
+
...entries.filter((entry) => !isThisSession(entry)),
|
|
1856
|
+
...entries.filter(isThisSession),
|
|
1857
|
+
];
|
|
1858
|
+
if (ordered.length <= TEST_RUNNER_MAX_PERSISTED_TARGETS)
|
|
1859
|
+
return ordered;
|
|
1860
|
+
dbg(`turn_end: ${label} test target list held ${ordered.length} entries, bounded to the newest ${TEST_RUNNER_MAX_PERSISTED_TARGETS} (oldest foreign-session rows dropped)`);
|
|
1861
|
+
return ordered.slice(-TEST_RUNNER_MAX_PERSISTED_TARGETS);
|
|
1862
|
+
};
|
|
1863
|
+
const writeTestFindings = (record, ownDeferred) => {
|
|
1864
|
+
const live = cacheManager.readCache("test-runner-findings", cwd)?.data;
|
|
1865
|
+
const own = mergeDeferredTargets(heldDeferred, ownDeferred);
|
|
1866
|
+
const ownKeys = new Set(own.map(entryPathKey));
|
|
1867
|
+
const merged = mergeDeferredTargets(live?.deferredTargets ?? [], own).filter((entry) => {
|
|
1868
|
+
if (!isThisSession(entry))
|
|
1869
|
+
return true;
|
|
1870
|
+
const key = entryPathKey(entry);
|
|
1871
|
+
return !resolvedDeferralKeys.has(key) || ownKeys.has(key);
|
|
1872
|
+
});
|
|
1873
|
+
cacheManager.writeCache("test-runner-findings", {
|
|
1874
|
+
...record,
|
|
1875
|
+
deferredTargets: boundPersisted(merged, "deferred"),
|
|
1876
|
+
retiredTargets: boundPersisted(mergeDeferredTargets(live?.retiredTargets ?? [], retiredCarry), "retired"),
|
|
1877
|
+
}, cwd);
|
|
1878
|
+
};
|
|
1879
|
+
let deadDeferred = 0;
|
|
1880
|
+
let heldOverCap = 0;
|
|
1881
|
+
let foreignDeferred = 0;
|
|
1882
|
+
let redispatchedDeferred = 0;
|
|
1883
|
+
let retiredThisTurn = 0;
|
|
1884
|
+
for (const carried of carriedDeferred) {
|
|
1885
|
+
const testFile = path.resolve(cwd, carried.testFile);
|
|
1886
|
+
const carriedKey = normalizeMapKey(testFile);
|
|
1887
|
+
// I1: another session still owes this run. Skip it, leave it on the
|
|
1888
|
+
// record — `writeTestFindings` never removes a row this turn does not
|
|
1889
|
+
// own.
|
|
1890
|
+
if (!isThisSession(carried)) {
|
|
1891
|
+
foreignDeferred++;
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
if (seen.has(carriedKey)) {
|
|
1895
|
+
resolvedDeferralKeys.add(carriedKey);
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
const attempts = carried.attempts ?? 0;
|
|
1899
|
+
if (attempts >= TEST_RUNNER_MAX_DEFERRALS) {
|
|
1900
|
+
resolvedDeferralKeys.add(carriedKey);
|
|
1901
|
+
if (!retiredKeys.has(carriedKey)) {
|
|
1902
|
+
retiredKeys.add(carriedKey);
|
|
1903
|
+
retiredThisTurn++;
|
|
1904
|
+
retiredCarry.push({
|
|
1905
|
+
testFile,
|
|
1906
|
+
runner: carried.runner,
|
|
1907
|
+
attempts,
|
|
1908
|
+
sessionId: turnSessionId,
|
|
1909
|
+
});
|
|
1910
|
+
// Never silent, and counted rather than once-per-subject: this is
|
|
1911
|
+
// the ledger entry that names WHICH suite is too slow to belong in
|
|
1912
|
+
// a per-turn batch at all.
|
|
1913
|
+
incrementDegradationCount({
|
|
1914
|
+
kind: "test-runner-batch-capped",
|
|
1915
|
+
subject: `${cwd}:deferral-exhausted`,
|
|
1916
|
+
reason: `test target ${path.relative(cwd, testFile)} was cut at the turn-end batch budget ${attempts} turn(s) running and is retired from turn-end selection for the rest of this session — too slow for a per-turn batch, run it explicitly`,
|
|
1917
|
+
});
|
|
1918
|
+
dbg(`turn_end: retiring deferred test target ${path.relative(cwd, testFile)} after ${attempts} cut batch(es) — too slow for the turn-end budget, run it explicitly`);
|
|
1919
|
+
}
|
|
1920
|
+
continue;
|
|
1921
|
+
}
|
|
1922
|
+
// A RunnerConfig carries functions, so the cache stores the runner KEY
|
|
1923
|
+
// and the config is re-resolved here from the single registry.
|
|
1924
|
+
const config = RUNNERS[carried.runner];
|
|
1925
|
+
if (!config ||
|
|
1926
|
+
isExcludedTestTarget(testFile, cwd) ||
|
|
1927
|
+
!fs.existsSync(testFile)) {
|
|
1928
|
+
resolvedDeferralKeys.add(carriedKey);
|
|
1929
|
+
deadDeferred++;
|
|
1930
|
+
continue;
|
|
1931
|
+
}
|
|
1932
|
+
if (targets.length >= TEST_RUNNER_MAX_TARGETS) {
|
|
1933
|
+
// I5: not settled — held, with `attempts` UNCHANGED. It was never
|
|
1934
|
+
// dispatched, so charging it toward retirement would retire a suite
|
|
1935
|
+
// this turn simply had no room for.
|
|
1936
|
+
heldOverCap++;
|
|
1937
|
+
heldDeferred.push({
|
|
1938
|
+
testFile,
|
|
1939
|
+
runner: carried.runner,
|
|
1940
|
+
attempts,
|
|
1941
|
+
sessionId: turnSessionId,
|
|
1942
|
+
});
|
|
1943
|
+
continue;
|
|
1944
|
+
}
|
|
1945
|
+
resolvedDeferralKeys.add(carriedKey);
|
|
1946
|
+
seen.add(carriedKey);
|
|
1947
|
+
redispatchedDeferred++;
|
|
1948
|
+
targets.push({
|
|
1949
|
+
testFile,
|
|
1950
|
+
runner: carried.runner,
|
|
1951
|
+
config,
|
|
1952
|
+
strategy: "deferred",
|
|
1953
|
+
deferralAttempts: attempts,
|
|
1954
|
+
});
|
|
1955
|
+
dbg(`turn_end: re-running deferred test target ${path.relative(cwd, testFile)} (${carried.runner}) from the previous turn's cut batch`);
|
|
1956
|
+
}
|
|
1957
|
+
if (deadDeferred > 0) {
|
|
1958
|
+
dbg(`turn_end: dropped ${deadDeferred} deferred test target(s) that no longer resolve (missing file, excluded, or unknown runner)`);
|
|
1959
|
+
}
|
|
1960
|
+
if (heldOverCap > 0) {
|
|
1961
|
+
dbg(`turn_end: held ${heldOverCap} deferred test target(s) over the per-turn cap of ${TEST_RUNNER_MAX_TARGETS} — kept on the deferral list for the next turn, attempts unchanged`);
|
|
1962
|
+
}
|
|
1963
|
+
if (foreignDeferred > 0) {
|
|
1964
|
+
dbg(`turn_end: ignored ${foreignDeferred} deferred test target(s) belonging to another session — left on the record for the session that cut them`);
|
|
1965
|
+
}
|
|
1966
|
+
if (carriedDeferred.length > 0) {
|
|
1967
|
+
// #2522 review round 4, S1: the ONE pushed record for this path. The
|
|
1968
|
+
// MCP / Stop-hook route calls `handleTurnEnd` with `dbg: noop`, so every
|
|
1969
|
+
// line above is invisible there and the deferral machinery — the part
|
|
1970
|
+
// that decides whether a suite runs at all — left no trace whatsoever
|
|
1971
|
+
// on the route that fires it most.
|
|
1972
|
+
logLatency({
|
|
1973
|
+
type: "phase",
|
|
1974
|
+
toolName: "turn_end",
|
|
1975
|
+
filePath: cwd,
|
|
1976
|
+
phase: "test_runner_deferral",
|
|
1977
|
+
durationMs: 0,
|
|
1978
|
+
metadata: {
|
|
1979
|
+
carried: carriedDeferred.length,
|
|
1980
|
+
redispatched: redispatchedDeferred,
|
|
1981
|
+
heldOverCap,
|
|
1982
|
+
retired: retiredThisTurn,
|
|
1983
|
+
dropped: deadDeferred,
|
|
1984
|
+
foreignSession: foreignDeferred,
|
|
1985
|
+
sessionId: turnSessionId,
|
|
1986
|
+
},
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
let overCapTargets = 0;
|
|
1990
|
+
let missingTargetFiles = 0;
|
|
1991
|
+
let excludedTargets = 0;
|
|
1992
|
+
let retiredSkips = 0;
|
|
1429
1993
|
for (const { display, abs, isNeighbor } of candidates) {
|
|
1430
|
-
const target = testRunnerClient.getTestRunTarget(abs, cwd);
|
|
1431
|
-
|
|
1432
|
-
|
|
1994
|
+
const target = testRunnerClient.getTestRunTarget(abs, cwd, runtime.turnIndex);
|
|
1995
|
+
const targetKey = target ? normalizeMapKey(target.testFile) : "";
|
|
1996
|
+
if (target && !seen.has(targetKey)) {
|
|
1997
|
+
seen.add(targetKey);
|
|
1998
|
+
// #2522 review round 3, F1: THE gate that makes the deferral cap a
|
|
1999
|
+
// cap. Whichever strategy produced this target — related, self,
|
|
2000
|
+
// failed-first — a target already retired this session for
|
|
2001
|
+
// outrunning the whole batch budget is not fired again. Without
|
|
2002
|
+
// this the retire branch above was decorative: it logged, and the
|
|
2003
|
+
// candidate loop three lines down re-resolved the same file and
|
|
2004
|
+
// spawned it anyway, with its attempt counter reset to zero.
|
|
2005
|
+
if (retiredKeys.has(targetKey)) {
|
|
2006
|
+
retiredSkips++;
|
|
2007
|
+
dbg(`turn_end: ${display} → test target retired earlier this session (outran the turn-end batch budget), skipping spawn (${path.relative(cwd, target.testFile)})`);
|
|
2008
|
+
continue;
|
|
2009
|
+
}
|
|
2010
|
+
// #2522: built-in exclusion for turn-end SELECTION — a resolved
|
|
2011
|
+
// target under an integration/e2e directory or naming convention
|
|
2012
|
+
// is never auto-fired, whichever strategy (failed-first/related/
|
|
2013
|
+
// self) produced it. No per-project config knob (maintainer
|
|
2014
|
+
// decision); see `TURN_END_EXCLUDED_TEST_GLOBS`.
|
|
2015
|
+
// LATENT HAZARD, deliberately left as-is: this `continue` skips the
|
|
2016
|
+
// candidate entirely, including `retireMissingFailedTargets`. If an
|
|
2017
|
+
// excluded target were ever seeded into the persisted failed set
|
|
2018
|
+
// (it cannot be today — nothing writes that set except a runner
|
|
2019
|
+
// RESULT, and an excluded target is never run, so it can never
|
|
2020
|
+
// produce one), it would sit there unretired forever, chosen by
|
|
2021
|
+
// the failed-first strategy on every turn and dropped here on
|
|
2022
|
+
// every turn. Any future writer of the failed set must retire
|
|
2023
|
+
// excluded entries at the write site, not here.
|
|
2024
|
+
if (isExcludedTestTarget(target.testFile, cwd)) {
|
|
2025
|
+
excludedTargets++;
|
|
2026
|
+
dbg(`turn_end: ${display} → test target excluded (integration/e2e), skipping spawn (${path.relative(cwd, target.testFile)})`);
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
2029
|
+
// #2504: a conventional target that no longer exists on disk still
|
|
2030
|
+
// cost a full runner spawn, which came back "Test file not found"
|
|
2031
|
+
// and was then dropped as an expected skip further down. 9 of the
|
|
2032
|
+
// reported turn's 59 spawns were this. One statSync is cheaper
|
|
2033
|
+
// than a vitest process by four orders of magnitude.
|
|
2034
|
+
if (!fs.existsSync(target.testFile)) {
|
|
2035
|
+
missingTargetFiles++;
|
|
2036
|
+
dbg(`turn_end: ${display} → test file missing, skipping spawn (${path.relative(cwd, target.testFile)})`);
|
|
2037
|
+
continue;
|
|
2038
|
+
}
|
|
2039
|
+
if (targets.length >= TEST_RUNNER_MAX_TARGETS) {
|
|
2040
|
+
overCapTargets++;
|
|
2041
|
+
continue;
|
|
2042
|
+
}
|
|
1433
2043
|
targets.push(target);
|
|
1434
2044
|
dbg(`turn_end: ${display} → test ${target.runner} ${path.relative(cwd, target.testFile)} (${target.strategy}${isNeighbor ? ", cascade-neighbor" : ""})`);
|
|
1435
2045
|
}
|
|
@@ -1437,11 +2047,30 @@ export async function handleTurnEnd(deps) {
|
|
|
1437
2047
|
dbg(`turn_end: ${display} → no test file found${isNeighbor ? " (cascade-neighbor)" : ""}`);
|
|
1438
2048
|
}
|
|
1439
2049
|
}
|
|
2050
|
+
if (excludedTargets > 0) {
|
|
2051
|
+
dbg(`turn_end: excluded ${excludedTargets} test target(s) under the built-in integration/e2e exclusion list`);
|
|
2052
|
+
}
|
|
2053
|
+
if (retiredSkips > 0) {
|
|
2054
|
+
dbg(`turn_end: skipped ${retiredSkips} test target(s) retired earlier this session for outrunning the turn-end batch budget`);
|
|
2055
|
+
}
|
|
2056
|
+
if (missingTargetFiles > 0) {
|
|
2057
|
+
dbg(`turn_end: skipped ${missingTargetFiles} test target(s) whose file no longer exists`);
|
|
2058
|
+
}
|
|
2059
|
+
if (overCapTargets > 0) {
|
|
2060
|
+
// Never silent: the agent is told that some of this turn's tests were
|
|
2061
|
+
// not run, rather than reading an all-green batch that covered part
|
|
2062
|
+
// of the edit set.
|
|
2063
|
+
recordDegradationOnce({
|
|
2064
|
+
kind: "test-runner-batch-capped",
|
|
2065
|
+
subject: cwd,
|
|
2066
|
+
reason: `turn touched more test targets than one turn may fire; ran ${TEST_RUNNER_MAX_TARGETS}, skipped ${overCapTargets} — re-run the remainder with lens_diagnostics or edit them in a smaller batch`,
|
|
2067
|
+
});
|
|
2068
|
+
dbg(`turn_end: test target count capped at ${TEST_RUNNER_MAX_TARGETS}, ${overCapTargets} skipped`);
|
|
2069
|
+
}
|
|
1440
2070
|
if (targets.length > 0) {
|
|
1441
|
-
dbg(`turn_end: firing ${targets.length} test target(s) async (non-blocking)`);
|
|
2071
|
+
dbg(`turn_end: firing ${targets.length} test target(s) async (non-blocking, max ${TEST_RUNNER_BATCH_CONCURRENCY} concurrent)`);
|
|
1442
2072
|
const firedAtTurn = runtime.turnIndex;
|
|
1443
|
-
const firedSessionId =
|
|
1444
|
-
const priorTestCache = cacheManager.readCache("test-runner-findings", cwd)?.data;
|
|
2073
|
+
const firedSessionId = turnSessionId;
|
|
1445
2074
|
const testRunGeneration = (priorTestCache?.testRunGeneration ?? 0) + 1;
|
|
1446
2075
|
const provenanceFiles = [
|
|
1447
2076
|
...candidates.map((candidate) => ({
|
|
@@ -1459,9 +2088,91 @@ export async function handleTurnEnd(deps) {
|
|
|
1459
2088
|
generation: testRunGeneration,
|
|
1460
2089
|
files: provenanceFiles,
|
|
1461
2090
|
});
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
2091
|
+
writeTestFindings({
|
|
2092
|
+
...(priorTestCache ?? { content: "" }),
|
|
2093
|
+
testRunGeneration,
|
|
2094
|
+
},
|
|
2095
|
+
// Consumed above into `targets` (or retired, or dropped). Anything
|
|
2096
|
+
// this turn HELD is re-asserted by the writer itself; the batch's own
|
|
2097
|
+
// outcome adds the NEW cut set below.
|
|
2098
|
+
[]);
|
|
2099
|
+
runTestTargetsBounded({
|
|
2100
|
+
targets,
|
|
2101
|
+
concurrency: TEST_RUNNER_BATCH_CONCURRENCY,
|
|
2102
|
+
budgetMs: TEST_RUNNER_BATCH_BUDGET_MS,
|
|
2103
|
+
// Both bounds, per AGENTS.md: a wall budget AND the ambient
|
|
2104
|
+
// abort signal the rest of the spawn layer already honours.
|
|
2105
|
+
signal: getAmbientAbortSignal(),
|
|
2106
|
+
run: (t, batchSignal) => testRunnerClient.runTestFileAsync(t.testFile, cwd, {
|
|
2107
|
+
runner: t.runner,
|
|
2108
|
+
config: t.config,
|
|
2109
|
+
turnIndex: firedAtTurn,
|
|
2110
|
+
// #2522 R2 F1: the BATCH's signal, so spending the 20s wall
|
|
2111
|
+
// budget kills this spawn rather than letting it run out its
|
|
2112
|
+
// own 60s timeout behind a batch that has already returned.
|
|
2113
|
+
signal: batchSignal,
|
|
2114
|
+
}),
|
|
2115
|
+
})
|
|
2116
|
+
.then(({ results, deferred, stopReason }) => {
|
|
2117
|
+
const deferredTargets = deferred.map((t) => ({
|
|
2118
|
+
testFile: t.testFile,
|
|
2119
|
+
runner: t.runner,
|
|
2120
|
+
// One more cut batch for this target. Read back by the
|
|
2121
|
+
// selection loop above, which retires it at
|
|
2122
|
+
// TEST_RUNNER_MAX_DEFERRALS rather than carrying it forever.
|
|
2123
|
+
attempts: (t.deferralAttempts ?? 0) + 1,
|
|
2124
|
+
// #2522 R3 F5: stamped with the session that cut it, so the
|
|
2125
|
+
// next session re-arms this target instead of inheriting an
|
|
2126
|
+
// attempt count measured under a load it never saw.
|
|
2127
|
+
sessionId: firedSessionId,
|
|
2128
|
+
}));
|
|
2129
|
+
if (deferred.length > 0) {
|
|
2130
|
+
// #2522 review round 2, F4: `incrementDegradationCount`, not
|
|
2131
|
+
// `recordDegradationOnce`. At a 20s budget this is a
|
|
2132
|
+
// RECURRING event, and a once-per-subject entry reports the
|
|
2133
|
+
// tenth cut batch of a session exactly like the first.
|
|
2134
|
+
incrementDegradationCount({
|
|
2135
|
+
kind: "test-runner-batch-capped",
|
|
2136
|
+
subject: `${cwd}:${stopReason ?? "incomplete"}`,
|
|
2137
|
+
reason: `test batch stopped early (${stopReason ?? "incomplete"}); ${deferred.length} target(s) killed or never dispatched, deferred to the next turn`,
|
|
2138
|
+
});
|
|
2139
|
+
dbg(`turn_end: test batch stopped early (${stopReason ?? "incomplete"}), ${deferred.length} target(s) deferred to the next turn`);
|
|
2140
|
+
}
|
|
2141
|
+
const deferralNote = deferred.length > 0
|
|
2142
|
+
? `${deferred.length} test target(s) did not finish within the turn-end batch budget and are deferred to the next turn (they run first): ${deferredTargets
|
|
2143
|
+
.map((t) => path.relative(cwd, t.testFile))
|
|
2144
|
+
.join(", ")}`
|
|
2145
|
+
: "";
|
|
2146
|
+
/**
|
|
2147
|
+
* A newer batch has already published for this project — this
|
|
2148
|
+
* one's results are stale by construction and must not overwrite
|
|
2149
|
+
* it. Was inlined three times; extracted once round 2 added a
|
|
2150
|
+
* fourth call site.
|
|
2151
|
+
*/
|
|
2152
|
+
const supersededByNewerGeneration = (label) => {
|
|
2153
|
+
const current = cacheManager.readCache("test-runner-findings", cwd)?.data;
|
|
2154
|
+
const currentGeneration = current?.testRunGeneration;
|
|
2155
|
+
if (currentGeneration !== undefined &&
|
|
2156
|
+
currentGeneration > testRunGeneration) {
|
|
2157
|
+
dbg(`turn_end: ${label}test generation ${testRunGeneration} superseded by ${currentGeneration}`);
|
|
2158
|
+
// #2522 review round 3, F3: returning here USED to drop this
|
|
2159
|
+
// batch's cut targets entirely. The newer batch's own pre-run
|
|
2160
|
+
// write already cleared them, and it never saw this set — so
|
|
2161
|
+
// with two overlapping batches the targets the older one was
|
|
2162
|
+
// cut on were never deferred, never re-selected, and the suite
|
|
2163
|
+
// they belong to simply never ran again. Hand them over
|
|
2164
|
+
// instead. Round 4, I2: the merge is `writeTestFindings`'s own
|
|
2165
|
+
// rule now, not a second copy of it here — every branch below
|
|
2166
|
+
// merges the same way, so a NEWER batch publishing after this
|
|
2167
|
+
// one no longer flattens what this hand-over just recorded.
|
|
2168
|
+
if (deferredTargets.length > 0) {
|
|
2169
|
+
writeTestFindings({ ...(current ?? { content: "" }) }, deferredTargets);
|
|
2170
|
+
dbg(`turn_end: ${label}carried ${deferredTargets.length} cut target(s) into the newer generation's deferral set`);
|
|
2171
|
+
}
|
|
2172
|
+
return true;
|
|
2173
|
+
}
|
|
2174
|
+
return false;
|
|
2175
|
+
};
|
|
1465
2176
|
const publishedAgainst = snapshotAdvisoryProvenance({
|
|
1466
2177
|
cwd,
|
|
1467
2178
|
runtime,
|
|
@@ -1484,13 +2195,40 @@ export async function handleTurnEnd(deps) {
|
|
|
1484
2195
|
const stale = runtime.turnIndex !== firedAtTurn;
|
|
1485
2196
|
const failures = [];
|
|
1486
2197
|
const resultValues = [];
|
|
2198
|
+
let rejectedCount = 0;
|
|
2199
|
+
// #2522: whether ANY reported item is a genuine failing test
|
|
2200
|
+
// (`failed > 0`) as opposed to the runner itself never
|
|
2201
|
+
// completing (timeout, missing provider/binary, a rejected
|
|
2202
|
+
// promise). Drives the delivery framing below — a batch made
|
|
2203
|
+
// up ENTIRELY of runner errors is not something the agent
|
|
2204
|
+
// introduced, so it must not read as "fix before continuing".
|
|
2205
|
+
let hasRealFailure = false;
|
|
1487
2206
|
for (const r of results) {
|
|
1488
2207
|
if (r.status === "rejected") {
|
|
2208
|
+
rejectedCount++;
|
|
2209
|
+
emitBounded("test_runner_delivery", `${cwd}:generation:${testRunGeneration}:rejected`, {
|
|
2210
|
+
filePath: cwd,
|
|
2211
|
+
durationMs: 0,
|
|
2212
|
+
metadata: {
|
|
2213
|
+
outcome: "runner-promise-rejected",
|
|
2214
|
+
sessionId: firedSessionId,
|
|
2215
|
+
generation: testRunGeneration,
|
|
2216
|
+
targetCount: targets.length,
|
|
2217
|
+
droppedDetailCount: 0,
|
|
2218
|
+
reason: String(r.reason).slice(0, 500),
|
|
2219
|
+
},
|
|
2220
|
+
}, {
|
|
2221
|
+
ledgerKind: "test-runner-delivery",
|
|
2222
|
+
reason: "test runner promise rejected",
|
|
2223
|
+
capPerTurn: { limit: 8, turnIndex: firedAtTurn },
|
|
2224
|
+
});
|
|
1489
2225
|
dbg(`turn_end: test run rejected — ${r.reason}`);
|
|
1490
2226
|
continue;
|
|
1491
2227
|
}
|
|
1492
2228
|
resultValues.push(r.value);
|
|
1493
2229
|
const { file, runner, passed, failed, duration, error } = r.value;
|
|
2230
|
+
if (failed > 0)
|
|
2231
|
+
hasRealFailure = true;
|
|
1494
2232
|
const shortFile = path.basename(file);
|
|
1495
2233
|
// #1479: `(0ms)` used to be printed for a run nobody
|
|
1496
2234
|
// timed — a payload with no suite timestamps, an
|
|
@@ -1512,19 +2250,33 @@ export async function handleTurnEnd(deps) {
|
|
|
1512
2250
|
// `elapsed` is: the pair read as a nested ternary, which
|
|
1513
2251
|
// this line only got flagged for because #1479 touched it.
|
|
1514
2252
|
const verdict = failed > 0 ? "FAIL" : "PASS";
|
|
1515
|
-
|
|
1516
|
-
|
|
2253
|
+
// #2532 review S1: folded onto `isRunnerErrorResult` instead of
|
|
2254
|
+
// re-deriving `error && passed === 0 && failed === 0` here — that
|
|
2255
|
+
// local spelling missed a runner error reported alongside partial
|
|
2256
|
+
// passes (pytest `Interrupted` after some tests already passed),
|
|
2257
|
+
// which read as a clean "PASS Np/0f" line with the error silently
|
|
2258
|
+
// dropped. A partial run says so explicitly rather than reading
|
|
2259
|
+
// as a clean pass.
|
|
2260
|
+
const summary = isRunnerErrorResult(r.value)
|
|
2261
|
+
? passed > 0
|
|
2262
|
+
? `error: ${error} (${passed} passed before)`
|
|
2263
|
+
: `error: ${error}`
|
|
1517
2264
|
: `${verdict} ${passed}p/${failed}f (${elapsed})`;
|
|
1518
2265
|
dbg(`turn_end: ${stale ? "[stale] " : ""}test ${runner} ${shortFile} → ${summary}`);
|
|
1519
2266
|
// #1524: also fires on `error` alone, not just `failed > 0`.
|
|
1520
|
-
// A runner
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
2267
|
+
// A runner error (the suite never started, or was
|
|
2268
|
+
// interrupted before finishing — spawn/config/timeout
|
|
2269
|
+
// failure) can arrive with `failed === 0` even when tests
|
|
2270
|
+
// DID pass before it (#2532 review S2 — not "by
|
|
2271
|
+
// construction": `parsePytestOutput` sets `error` from the
|
|
2272
|
+
// exit code independently of the parsed counts, so
|
|
2273
|
+
// `isRunnerErrorResult` is `failed === 0 && !!error`, not an
|
|
2274
|
+
// invariant elsewhere). Gating on `failed > 0` alone dropped
|
|
2275
|
+
// it silently: the agent got no context at all, and the
|
|
2276
|
+
// empty `failures` array below sent this result down the
|
|
2277
|
+
// "all tests passed" branch, clearing any prior real
|
|
2278
|
+
// test-failure git-guard blocker. `formatResult` already
|
|
2279
|
+
// renders the error-only case as "Could not run tests: ...".
|
|
1528
2280
|
if (failed > 0 || error) {
|
|
1529
2281
|
// #2028: "Test file not found" is an expected skip
|
|
1530
2282
|
// (conventional test path without an actual file),
|
|
@@ -1538,17 +2290,32 @@ export async function handleTurnEnd(deps) {
|
|
|
1538
2290
|
failures.push(formatted);
|
|
1539
2291
|
}
|
|
1540
2292
|
}
|
|
2293
|
+
if (rejectedCount > 0) {
|
|
2294
|
+
failures.push(`Test runner rejected ${rejectedCount} promise(s) before producing a structured result.`);
|
|
2295
|
+
}
|
|
1541
2296
|
if (failures.length > 0) {
|
|
1542
|
-
|
|
1543
|
-
if (currentGeneration !== undefined &&
|
|
1544
|
-
currentGeneration > testRunGeneration) {
|
|
1545
|
-
dbg(`turn_end: test generation ${testRunGeneration} superseded by ${currentGeneration}`);
|
|
2297
|
+
if (supersededByNewerGeneration(""))
|
|
1546
2298
|
return;
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
2299
|
+
const content = [
|
|
2300
|
+
stale
|
|
2301
|
+
? "[from a prior turn — the edit that triggered this run had already been superseded by the time results came back]"
|
|
2302
|
+
: "",
|
|
2303
|
+
failures.join("\n\n"),
|
|
2304
|
+
// A batch can BOTH find a real failure and be cut short.
|
|
2305
|
+
// The agent needs to see the failure AND that the run was
|
|
2306
|
+
// incomplete, or it reads a partial list as the full one.
|
|
2307
|
+
deferralNote,
|
|
2308
|
+
]
|
|
2309
|
+
.filter(Boolean)
|
|
2310
|
+
.join("\n\n");
|
|
2311
|
+
// #2522: nothing in this batch is a genuine failing test —
|
|
2312
|
+
// every entry is a runner error (timeout, missing
|
|
2313
|
+
// provider/binary, a rejected promise). `peekTestFindings`
|
|
2314
|
+
// reads this to deliver advisory framing instead of
|
|
2315
|
+
// "fix before continuing", since the agent introduced
|
|
2316
|
+
// nothing here to fix.
|
|
2317
|
+
const runnerErrorOnly = !hasRealFailure;
|
|
2318
|
+
writeTestFindings({
|
|
1552
2319
|
content,
|
|
1553
2320
|
stale,
|
|
1554
2321
|
results: resultValues,
|
|
@@ -1557,7 +2324,20 @@ export async function handleTurnEnd(deps) {
|
|
|
1557
2324
|
publishedAgainst,
|
|
1558
2325
|
provenance: publishedAgainst,
|
|
1559
2326
|
superseded,
|
|
1560
|
-
|
|
2327
|
+
runnerErrorOnly,
|
|
2328
|
+
}, deferredTargets);
|
|
2329
|
+
try {
|
|
2330
|
+
deps.onTestRunnerComplete?.({
|
|
2331
|
+
cwd,
|
|
2332
|
+
sessionId: firedSessionId,
|
|
2333
|
+
generation: testRunGeneration,
|
|
2334
|
+
targetCount: targets.length,
|
|
2335
|
+
hasFindings: true,
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
catch (deliveryErr) {
|
|
2339
|
+
dbg(`turn_end: test delivery staging failed — ${deliveryErr}`);
|
|
2340
|
+
}
|
|
1561
2341
|
if (getFlag("lens-guard") &&
|
|
1562
2342
|
firedSessionId === runtime.telemetrySessionId) {
|
|
1563
2343
|
// #1524: `&& !value.error` — a runner-error result has
|
|
@@ -1579,13 +2359,117 @@ export async function handleTurnEnd(deps) {
|
|
|
1579
2359
|
if (cleanFiles.length > 0) {
|
|
1580
2360
|
clearGitGuardTestFailure(cacheManager, cwd, runtime, cleanFiles);
|
|
1581
2361
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
2362
|
+
// #2532: `runnerErrorOnly` (computed above for the turn-end
|
|
2363
|
+
// delivery framing) is true exactly when nothing in this
|
|
2364
|
+
// batch is a genuine failing test — every entry is a
|
|
2365
|
+
// runner error the agent did not introduce. Without this
|
|
2366
|
+
// gate, an all-runner-error batch still called
|
|
2367
|
+
// `mergeGitGuardTestFailure` with an EMPTY failed-files
|
|
2368
|
+
// list, which unconditionally sets `hasBlockers: true`:
|
|
2369
|
+
// the identical event the turn-end message reports as
|
|
2370
|
+
// advisory read as "COMMIT BLOCKED" under --lens-guard.
|
|
2371
|
+
if (!runnerErrorOnly) {
|
|
2372
|
+
mergeGitGuardTestFailure(cacheManager, cwd, runtime, content, resultValues
|
|
2373
|
+
.filter((value) => value.failed > 0)
|
|
2374
|
+
.map((value) => value.file));
|
|
2375
|
+
}
|
|
2376
|
+
else {
|
|
2377
|
+
// #2532 review T2: the skip above is otherwise pull-only —
|
|
2378
|
+
// nothing records that a batch with real content
|
|
2379
|
+
// (`failures.length > 0`) was deliberately kept OFF the
|
|
2380
|
+
// `--lens-guard` blocker because every entry was a runner
|
|
2381
|
+
// error. Same phase/ledger as the rejected-promise event
|
|
2382
|
+
// above, so both `--lens-guard` demotions land in one
|
|
2383
|
+
// queryable place. Reached only inside the enclosing
|
|
2384
|
+
// `getFlag("lens-guard") && firedSessionId === …` check —
|
|
2385
|
+
// no point recording a demotion the flag can't act on.
|
|
2386
|
+
emitBounded("test_runner_delivery", `${cwd}:generation:${testRunGeneration}:runner-error-only`, {
|
|
2387
|
+
filePath: cwd,
|
|
2388
|
+
durationMs: 0,
|
|
2389
|
+
metadata: {
|
|
2390
|
+
outcome: "runner-error-only-not-blocking",
|
|
2391
|
+
sessionId: firedSessionId,
|
|
2392
|
+
generation: testRunGeneration,
|
|
2393
|
+
targetCount: targets.length,
|
|
2394
|
+
droppedDetailCount: 0,
|
|
2395
|
+
},
|
|
2396
|
+
}, {
|
|
2397
|
+
ledgerKind: "test-runner-delivery",
|
|
2398
|
+
reason: "runner-error-only batch kept off the --lens-guard blocker",
|
|
2399
|
+
capPerTurn: { limit: 8, turnIndex: firedAtTurn },
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
dbg(`turn_end: ${failures.length} test failure(s) cached for pull diagnostics and post-agent delivery${stale ? " (stale — turn advanced while tests ran)" : ""}`);
|
|
2404
|
+
}
|
|
2405
|
+
else if (deferred.length > 0) {
|
|
2406
|
+
// #2522 review round 2, F1: a PARTIAL batch is not a clean
|
|
2407
|
+
// batch. Pre-round-2 this fell through to the branch below —
|
|
2408
|
+
// `content: ""`, "all tests passed", and a `--lens-guard`
|
|
2409
|
+
// clear — on the strength of a run that never finished. The
|
|
2410
|
+
// record names the deferral instead; the commit gate is left
|
|
2411
|
+
// exactly as it was, because nothing here proves anything
|
|
2412
|
+
// passed that was not already proven.
|
|
2413
|
+
if (supersededByNewerGeneration("deferred "))
|
|
2414
|
+
return;
|
|
2415
|
+
writeTestFindings({
|
|
2416
|
+
content: deferralNote,
|
|
2417
|
+
stale,
|
|
2418
|
+
results: resultValues,
|
|
2419
|
+
testRunGeneration,
|
|
2420
|
+
launchedFrom,
|
|
2421
|
+
publishedAgainst,
|
|
2422
|
+
provenance: publishedAgainst,
|
|
2423
|
+
superseded,
|
|
2424
|
+
// Advisory framing: an unfinished batch is not a
|
|
2425
|
+
// failure the agent introduced, exactly like a runner
|
|
2426
|
+
// error. See `TestRunnerFindingsCache.runnerErrorOnly`.
|
|
2427
|
+
runnerErrorOnly: true,
|
|
2428
|
+
}, deferredTargets);
|
|
2429
|
+
try {
|
|
2430
|
+
deps.onTestRunnerComplete?.({
|
|
2431
|
+
cwd,
|
|
2432
|
+
sessionId: firedSessionId,
|
|
2433
|
+
generation: testRunGeneration,
|
|
2434
|
+
targetCount: targets.length,
|
|
2435
|
+
hasFindings: true,
|
|
2436
|
+
});
|
|
1585
2437
|
}
|
|
1586
|
-
|
|
2438
|
+
catch (deliveryErr) {
|
|
2439
|
+
dbg(`turn_end: test delivery staging failed — ${deliveryErr}`);
|
|
2440
|
+
}
|
|
2441
|
+
dbg(`turn_end: partial test batch — ${results.length} target(s) settled, ${deferred.length} deferred to the next turn; NOT recorded as a clean run`);
|
|
1587
2442
|
}
|
|
1588
2443
|
else if (results.length > 0) {
|
|
2444
|
+
if (supersededByNewerGeneration("clean "))
|
|
2445
|
+
return;
|
|
2446
|
+
writeTestFindings({
|
|
2447
|
+
...(priorTestCache ?? { content: "" }),
|
|
2448
|
+
content: "",
|
|
2449
|
+
stale: false,
|
|
2450
|
+
results: resultValues,
|
|
2451
|
+
testRunGeneration,
|
|
2452
|
+
launchedFrom,
|
|
2453
|
+
publishedAgainst,
|
|
2454
|
+
provenance: publishedAgainst,
|
|
2455
|
+
superseded,
|
|
2456
|
+
},
|
|
2457
|
+
// Nothing was cut. This turn's own settled entries drop out;
|
|
2458
|
+
// I2 keeps every row this batch does not own — including a
|
|
2459
|
+
// concurrent older batch's hand-over.
|
|
2460
|
+
[]);
|
|
2461
|
+
try {
|
|
2462
|
+
deps.onTestRunnerComplete?.({
|
|
2463
|
+
cwd,
|
|
2464
|
+
sessionId: firedSessionId,
|
|
2465
|
+
generation: testRunGeneration,
|
|
2466
|
+
targetCount: targets.length,
|
|
2467
|
+
hasFindings: false,
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
catch (deliveryErr) {
|
|
2471
|
+
dbg(`turn_end: test delivery staging failed — ${deliveryErr}`);
|
|
2472
|
+
}
|
|
1589
2473
|
if (getFlag("lens-guard") &&
|
|
1590
2474
|
firedSessionId === runtime.telemetrySessionId) {
|
|
1591
2475
|
clearGitGuardTestFailure(cacheManager, cwd, runtime, resultValues.map((value) => value.file));
|
|
@@ -1595,6 +2479,17 @@ export async function handleTurnEnd(deps) {
|
|
|
1595
2479
|
})
|
|
1596
2480
|
.catch(() => { });
|
|
1597
2481
|
}
|
|
2482
|
+
else if (carriedDeferred.length > 0) {
|
|
2483
|
+
// Nothing fired, so no batch outcome will write the list back, but this
|
|
2484
|
+
// turn still settled part of it (targets retired at the cap, or dropped
|
|
2485
|
+
// because their file is gone). Persist that here rather than re-reading
|
|
2486
|
+
// a set of dead paths on every future turn; `writeTestFindings` carries
|
|
2487
|
+
// this turn's retirements alongside, which is what the deferral-cap
|
|
2488
|
+
// branch above depends on when it retires the LAST carried target and
|
|
2489
|
+
// fires nothing. Rows this turn only HELD (over the cap) or does not
|
|
2490
|
+
// own (another session's) are re-asserted by the writer itself.
|
|
2491
|
+
writeTestFindings({ ...(priorTestCache ?? { content: "" }) }, []);
|
|
2492
|
+
}
|
|
1598
2493
|
}
|
|
1599
2494
|
if (runtime.errorDebtBaseline && files.length > 0) {
|
|
1600
2495
|
dbg("turn_end: marking error debt check for next session");
|
|
@@ -1728,10 +2623,79 @@ export async function handleTurnEnd(deps) {
|
|
|
1728
2623
|
fileSeqByPath,
|
|
1729
2624
|
deltaOnly: !getFlag("lens-actionable-warning-all"),
|
|
1730
2625
|
dbg,
|
|
2626
|
+
// #2504: this call is AWAITED on the turn_end hook, so its cost is
|
|
2627
|
+
// terminal-blocking time. The file cap and wall budget are its
|
|
2628
|
+
// bounds; the abort signal is the second one AGENTS.md requires.
|
|
2629
|
+
signal: getAmbientAbortSignal(),
|
|
2630
|
+
// A cold-cache turn hands the fresh-pull loop back here, off the
|
|
2631
|
+
// hook — it lands in the same cache the in-band report goes to.
|
|
2632
|
+
onDeferredReport: (deferred) => {
|
|
2633
|
+
try {
|
|
2634
|
+
// #2504 review round 2 (F2): GUARDED. This callback fires up
|
|
2635
|
+
// to a minute after the turn that armed it, and the report
|
|
2636
|
+
// it carries is stamped with THAT turn's
|
|
2637
|
+
// turnIndex/projectSeq. Writing it unconditionally
|
|
2638
|
+
// overwrote a newer report, which `agent_end` then rejected
|
|
2639
|
+
// as `project_seq_mismatch` (silently skipping the autofix
|
|
2640
|
+
// pass) and `lens_diagnostics` re-served as a stale delta.
|
|
2641
|
+
// #2504 review round 4 (F1): guarded PER FILE, not per
|
|
2642
|
+
// report. The round-3 shape (publish, or discard whole
|
|
2643
|
+
// when something newer is persisted) composed with
|
|
2644
|
+
// incumbent-wins into "publish nothing" — every turn_end
|
|
2645
|
+
// with modified files persists an in-band report with a
|
|
2646
|
+
// strictly increasing turnIndex, and the decline fires
|
|
2647
|
+
// exactly when such a turn runs while a loop is in
|
|
2648
|
+
// flight, so a decline always implied a supersede. The
|
|
2649
|
+
// merge upserts the entries whose file has not moved and
|
|
2650
|
+
// drops only those that have.
|
|
2651
|
+
writeDeferredActionableWarningsReport({
|
|
2652
|
+
cacheManager,
|
|
2653
|
+
cwd,
|
|
2654
|
+
report: deferred,
|
|
2655
|
+
// The LIVE per-file sequence at WRITE time is the
|
|
2656
|
+
// baseline the merge judges each entry against. The
|
|
2657
|
+
// runtime is the only thing that knows it: a file
|
|
2658
|
+
// edited into cleanliness by a later turn is absent
|
|
2659
|
+
// from the persisted report entirely.
|
|
2660
|
+
getFileSeq: getFileSeq
|
|
2661
|
+
? (filePath) => getFileSeq.call(runtime, filePath)
|
|
2662
|
+
: undefined,
|
|
2663
|
+
dbg,
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
catch (deferErr) {
|
|
2667
|
+
dbg(`turn_end: deferred actionable-warnings write failed — ${deferErr}`);
|
|
2668
|
+
}
|
|
2669
|
+
},
|
|
2670
|
+
});
|
|
2671
|
+
// #2504 review round 5 (F1): THE publish choke point. This was a
|
|
2672
|
+
// blind `writeActionableWarningsReport` while the deferred callback
|
|
2673
|
+
// above read-modify-wrote the SAME cache key. The deferred merge can
|
|
2674
|
+
// land anywhere inside this handleTurnEnd -- the cascade settle,
|
|
2675
|
+
// knip, madge, the test batch, the in-band LSP enrichment are all
|
|
2676
|
+
// awaited between the moment it is armed and the moment we get
|
|
2677
|
+
// here -- and the blind write erased it 607 ms later in the
|
|
2678
|
+
// reviewer's trace. The publisher now always reads what is persisted
|
|
2679
|
+
// and merges per file, so the two writers cannot race. It carries
|
|
2680
|
+
// forward only entries a DEFERRAL produced, and only while their
|
|
2681
|
+
// file has not moved, so a `turn_delta` report does not accumulate
|
|
2682
|
+
// every prior turn's findings.
|
|
2683
|
+
const publishResult = publishActionableWarningsReport(cacheManager, cwd, report, {
|
|
2684
|
+
origin: "in-band",
|
|
2685
|
+
getFileSeq: getFileSeq
|
|
2686
|
+
? (filePath) => getFileSeq.call(runtime, filePath)
|
|
2687
|
+
: undefined,
|
|
2688
|
+
dbg,
|
|
1731
2689
|
});
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
2690
|
+
// #2504 review round 6 (a): the advisory must read the MERGED report,
|
|
2691
|
+
// not the pre-merge `report` this turn assembled -- a rescued deferred
|
|
2692
|
+
// entry lives only on `publishResult.report`, and formatting the
|
|
2693
|
+
// pre-merge report silently dropped it from the turn_end advisory
|
|
2694
|
+
// even though it was correctly persisted to cache.
|
|
2695
|
+
// #2521: `cwd` is what resolves the store location the advisory
|
|
2696
|
+
// names -- it is the SAME cwd `publishActionableWarningsReport`
|
|
2697
|
+
// just wrote through, so the two cannot disagree.
|
|
2698
|
+
const advisory = formatActionableWarningsAdvisory(publishResult.report, cwd);
|
|
1735
2699
|
// @delivery-surface: runtime-turn:actionable-warnings-advisory
|
|
1736
2700
|
if (advisory)
|
|
1737
2701
|
advisoryParts.push(advisory);
|
|
@@ -1740,7 +2704,13 @@ export async function handleTurnEnd(deps) {
|
|
|
1740
2704
|
sessionId: runtime.telemetrySessionId,
|
|
1741
2705
|
metadata: {
|
|
1742
2706
|
turnIndex: runtime.turnIndex,
|
|
1743
|
-
|
|
2707
|
+
// #2504 review round 7 (F2): the MERGED report, matching the
|
|
2708
|
+
// advisory text above it (round 6, a) -- a rescued deferred
|
|
2709
|
+
// entry lives only on `publishResult.report`, and the pre-merge
|
|
2710
|
+
// `report` this turn assembled undercounts it. This is the value
|
|
2711
|
+
// scripts/analyze-pi-lens-logs.mjs sums, so an undercount here is
|
|
2712
|
+
// a silent miscount there too.
|
|
2713
|
+
unsuppressed: publishResult.report.summary.unsuppressed,
|
|
1744
2714
|
},
|
|
1745
2715
|
});
|
|
1746
2716
|
logLatency({
|
|
@@ -1781,7 +2751,7 @@ export async function handleTurnEnd(deps) {
|
|
|
1781
2751
|
});
|
|
1782
2752
|
writeCodeQualityWarningsReport(cacheManager, cwd, qualityReport);
|
|
1783
2753
|
appendCodeQualityWarningsHistory(cwd, qualityReport);
|
|
1784
|
-
const advisory = formatCodeQualityWarningsAdvisory(qualityReport);
|
|
2754
|
+
const advisory = formatCodeQualityWarningsAdvisory(qualityReport, cwd);
|
|
1785
2755
|
// @delivery-surface: runtime-turn:code-quality-warnings-advisory
|
|
1786
2756
|
if (advisory)
|
|
1787
2757
|
advisoryParts.push(advisory);
|
|
@@ -1809,6 +2779,78 @@ export async function handleTurnEnd(deps) {
|
|
|
1809
2779
|
});
|
|
1810
2780
|
}
|
|
1811
2781
|
cacheManager.incrementTurnCycle(cwd, currentOwner);
|
|
2782
|
+
// Collect-later CLI runners continue off the write path. Their completed
|
|
2783
|
+
// diagnostics use the same freshness gate as late auxiliary findings and
|
|
2784
|
+
// enter the ordinary turn-end advisory delivery channel.
|
|
2785
|
+
const runnerFindingsStart = Date.now();
|
|
2786
|
+
// Turn-end delivery is deliberately non-blocking. Collect already-settled
|
|
2787
|
+
// results and requeue the rest; the edit path already paid the deferral
|
|
2788
|
+
// decision, so another 2s wait would charge every turn while a runner is
|
|
2789
|
+
// still in flight (#2122 F5).
|
|
2790
|
+
const pendingRunnerFindings = await drainPendingRunnerFindings(0);
|
|
2791
|
+
let runnerFindingsDelivered = 0;
|
|
2792
|
+
let runnerFindingsStale = 0;
|
|
2793
|
+
let runnerFindingsFailed = 0;
|
|
2794
|
+
let runnerFindingsDropped = 0;
|
|
2795
|
+
const runnerFindingsDeliveredIds = [];
|
|
2796
|
+
for (const pending of pendingRunnerFindings) {
|
|
2797
|
+
const result = pending.result;
|
|
2798
|
+
if (!result)
|
|
2799
|
+
continue;
|
|
2800
|
+
recordRunner(pending.filePath, pending.runnerId, result.status, result.diagnostics.length, Date.now() - pending.markedAtMs, pending.writeIndex);
|
|
2801
|
+
if (result.status === "failed") {
|
|
2802
|
+
runnerFindingsFailed += 1;
|
|
2803
|
+
const detail = result.failureMessage ? `: ${result.failureMessage}` : "";
|
|
2804
|
+
// @delivery-surface: runtime-turn:late-runner-findings
|
|
2805
|
+
advisoryParts.push(`❌ Deferred runner ${pending.runnerId} failed (${result.failureKind ?? "unknown"})${detail}`);
|
|
2806
|
+
continue;
|
|
2807
|
+
}
|
|
2808
|
+
const findings = result.diagnostics;
|
|
2809
|
+
if (findings.length === 0)
|
|
2810
|
+
continue;
|
|
2811
|
+
const gate = gateFindingsByPathFreshness({
|
|
2812
|
+
store: "late-runner-findings",
|
|
2813
|
+
findings,
|
|
2814
|
+
cwd,
|
|
2815
|
+
scannedAt: pending.markedAtMs,
|
|
2816
|
+
citedPath: (finding) => finding.filePath,
|
|
2817
|
+
});
|
|
2818
|
+
runnerFindingsStale += gate.stale.length;
|
|
2819
|
+
if (gate.stale.length > 0) {
|
|
2820
|
+
// The runner answered for bytes older than the latest edit. Do not
|
|
2821
|
+
// re-arm this completed answer: only a new runner query can restore
|
|
2822
|
+
// coverage for the refreshed bytes.
|
|
2823
|
+
dropStaleRunnerFindings(pending);
|
|
2824
|
+
runnerFindingsDropped += 1;
|
|
2825
|
+
}
|
|
2826
|
+
if (gate.live.length === 0)
|
|
2827
|
+
continue;
|
|
2828
|
+
const displayPath = toRunnerDisplayPath(cwd, pending.filePath);
|
|
2829
|
+
const lines = gate.live.map((finding) => ` ${displayPath}:${finding.line ?? 1}:${finding.column ?? 1} [${finding.rule ?? finding.id}] ${finding.message}`);
|
|
2830
|
+
runnerFindingsDelivered += gate.live.length;
|
|
2831
|
+
for (const finding of gate.live) {
|
|
2832
|
+
if (runnerFindingsDeliveredIds.length < 50) {
|
|
2833
|
+
runnerFindingsDeliveredIds.push(finding.id);
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
// @delivery-surface: runtime-turn:late-runner-findings
|
|
2837
|
+
advisoryParts.push(`⏱️ Late runner diagnostics (${pending.runnerId} completed after the edit):\n${lines.join("\n")}`);
|
|
2838
|
+
}
|
|
2839
|
+
logLatency({
|
|
2840
|
+
type: "phase",
|
|
2841
|
+
toolName: "turn_end",
|
|
2842
|
+
filePath: cwd,
|
|
2843
|
+
phase: "late_runner_findings",
|
|
2844
|
+
durationMs: Date.now() - runnerFindingsStart,
|
|
2845
|
+
metadata: {
|
|
2846
|
+
pending: pendingRunnerFindings.length,
|
|
2847
|
+
delivered: runnerFindingsDelivered,
|
|
2848
|
+
stale: runnerFindingsStale,
|
|
2849
|
+
failed: runnerFindingsFailed,
|
|
2850
|
+
dropped: runnerFindingsDropped,
|
|
2851
|
+
deliveredIds: runnerFindingsDeliveredIds,
|
|
2852
|
+
},
|
|
2853
|
+
});
|
|
1812
2854
|
// #2001/#2002: collect-later delivery for auxiliary LSP servers whose
|
|
1813
2855
|
// aux-grace window expired without a publication (opengrep on Windows:
|
|
1814
2856
|
// ~8s per scan against a 2s grace — the scanner's eventual findings sat
|
|
@@ -1820,12 +2862,25 @@ export async function handleTurnEnd(deps) {
|
|
|
1820
2862
|
// client drops silently.
|
|
1821
2863
|
const lateAuxStart = Date.now();
|
|
1822
2864
|
const drainedPairs = drainPendingAuxiliaryCoverage();
|
|
2865
|
+
// #2168: cap evictions retire a pair before any drain can observe it — read
|
|
2866
|
+
// and reset that count here so it folds into this turn's reconciliation
|
|
2867
|
+
// sum instead of the pair vanishing uncounted.
|
|
2868
|
+
const lateAuxCapEvicted = drainPendingAuxCapEvictedCount();
|
|
1823
2869
|
let lateAuxDelivered = 0;
|
|
1824
2870
|
let lateAuxStale = 0;
|
|
1825
2871
|
let lateAuxMissing = 0;
|
|
1826
2872
|
let lateAuxRearmed = 0;
|
|
1827
2873
|
let lateAuxClientGone = 0;
|
|
1828
2874
|
let lateAuxProbeFailed = 0;
|
|
2875
|
+
let lateAuxCleanConfirmed = 0;
|
|
2876
|
+
let lateAuxExpired = 0;
|
|
2877
|
+
let lateAuxCeilingExhausted = 0;
|
|
2878
|
+
let lateAuxAnswered = 0;
|
|
2879
|
+
let lateAuxNotifyStallDemoted = 0;
|
|
2880
|
+
const lateAuxCoverageGapPairs = [];
|
|
2881
|
+
let lateAuxCoverageGapDetailCount = 0;
|
|
2882
|
+
let lateAuxCoverageGapDropCount = 0;
|
|
2883
|
+
const lateAuxStuckPairs = [];
|
|
1829
2884
|
if (drainedPairs.length > 0) {
|
|
1830
2885
|
const byFile = new Map();
|
|
1831
2886
|
for (const pair of drainedPairs) {
|
|
@@ -1843,23 +2898,76 @@ export async function handleTurnEnd(deps) {
|
|
|
1843
2898
|
cached = await service.readCachedDiagnosticsForServers(lateAuxPath, new Set(pairs.map((p) => p.serverId)));
|
|
1844
2899
|
}
|
|
1845
2900
|
catch {
|
|
1846
|
-
// #2027 round-1 P3-2
|
|
2901
|
+
// #2027 round-1 P3-2 / #2167 R2-2: a transient probe rejection
|
|
2902
|
+
// tells us nothing about the pair's content, so treat it like the
|
|
2903
|
+
// "still scanning" branch below — re-arm under the SAME
|
|
2904
|
+
// ceiling/TTL bound rather than dropping the coverage outright.
|
|
2905
|
+
// `probeFailed` stays an honest per-turn failure count; it is
|
|
2906
|
+
// informational (like `stale`/`missing`), not a terminal bucket,
|
|
2907
|
+
// since the pair itself still resolves through rearmed/
|
|
2908
|
+
// ceilingExhausted/expired below.
|
|
1847
2909
|
lateAuxProbeFailed += pairs.length;
|
|
1848
2910
|
for (const pair of pairs) {
|
|
1849
|
-
|
|
2911
|
+
if (!isPendingAuxiliaryPastRearmTtl(pair) &&
|
|
2912
|
+
(pair.rearmCount ?? 0) < MAX_LATE_AUX_REARMS) {
|
|
2913
|
+
rearmPendingAuxiliaryCoverage(pair);
|
|
2914
|
+
lateAuxRearmed += 1;
|
|
2915
|
+
}
|
|
2916
|
+
else if (isPendingAuxiliaryPastRearmTtl(pair)) {
|
|
2917
|
+
lateAuxExpired += 1;
|
|
2918
|
+
}
|
|
2919
|
+
else {
|
|
2920
|
+
lateAuxCeilingExhausted += 1;
|
|
2921
|
+
}
|
|
1850
2922
|
}
|
|
1851
2923
|
continue;
|
|
1852
2924
|
}
|
|
1853
2925
|
const displayLateAuxPath = toRunnerDisplayPath(cwd, lateAuxPath);
|
|
1854
2926
|
for (const pair of pairs) {
|
|
1855
|
-
const
|
|
1856
|
-
if (
|
|
2927
|
+
const cachedEntry = cached.get(pair.serverId);
|
|
2928
|
+
if (cachedEntry === undefined) {
|
|
1857
2929
|
// No live client for this server any more — best-effort probe,
|
|
1858
2930
|
// drop the pair silently.
|
|
1859
2931
|
lateAuxClientGone += 1;
|
|
1860
2932
|
continue;
|
|
1861
2933
|
}
|
|
1862
|
-
if (
|
|
2934
|
+
if (cachedEntry.notifyStallDemoted) {
|
|
2935
|
+
if (cachedEntry.demotedAt === undefined ||
|
|
2936
|
+
pair.markedAtMs > cachedEntry.demotedAt) {
|
|
2937
|
+
// A pair marked after teardown belongs to the missing
|
|
2938
|
+
// generation, so it follows ordinary clientGone handling.
|
|
2939
|
+
lateAuxClientGone += 1;
|
|
2940
|
+
continue;
|
|
2941
|
+
}
|
|
2942
|
+
// #2356: notify-stall teardown is a transient absence while the
|
|
2943
|
+
// breaker cools down, but only for a pair marked before teardown.
|
|
2944
|
+
lateAuxNotifyStallDemoted += 1;
|
|
2945
|
+
const pastTtl = isPendingAuxiliaryPastRearmTtl(pair);
|
|
2946
|
+
const atCeiling = (pair.rearmCount ?? 0) >= MAX_LATE_AUX_REARMS;
|
|
2947
|
+
if (!pastTtl && !atCeiling) {
|
|
2948
|
+
rearmPendingAuxiliaryCoverage(pair);
|
|
2949
|
+
lateAuxRearmed += 1;
|
|
2950
|
+
if (lateAuxStuckPairs.length < 20)
|
|
2951
|
+
lateAuxStuckPairs.push({
|
|
2952
|
+
filePath: pair.filePath,
|
|
2953
|
+
serverId: pair.serverId,
|
|
2954
|
+
});
|
|
2955
|
+
}
|
|
2956
|
+
else {
|
|
2957
|
+
if (pastTtl)
|
|
2958
|
+
lateAuxExpired += 1;
|
|
2959
|
+
else
|
|
2960
|
+
lateAuxCeilingExhausted += 1;
|
|
2961
|
+
lateAuxCoverageGapPairs.push({
|
|
2962
|
+
filePath: pair.filePath,
|
|
2963
|
+
serverId: pair.serverId,
|
|
2964
|
+
});
|
|
2965
|
+
}
|
|
2966
|
+
continue;
|
|
2967
|
+
}
|
|
2968
|
+
const rawDiags = cachedEntry.diags;
|
|
2969
|
+
if (cachedEntry.publishedAt === undefined ||
|
|
2970
|
+
cachedEntry.publishedAt <= pair.markedAtMs) {
|
|
1863
2971
|
// Still scanning (or published nothing yet) — keep waiting
|
|
1864
2972
|
// so a scan finishing before the NEXT turn end still
|
|
1865
2973
|
// delivers. Two clocks, deliberately decoupled: the
|
|
@@ -1867,17 +2975,36 @@ export async function handleTurnEnd(deps) {
|
|
|
1867
2975
|
// the delivery gate stats against — while the re-arm TTL is
|
|
1868
2976
|
// anchored on `lastRearmedAtMs`, advanced by every successful
|
|
1869
2977
|
// empty probe: the scanner is demonstrably alive, just slow.
|
|
1870
|
-
if (!isPendingAuxiliaryPastRearmTtl(pair)
|
|
1871
|
-
|
|
2978
|
+
if (!isPendingAuxiliaryPastRearmTtl(pair) &&
|
|
2979
|
+
(pair.rearmCount ?? 0) < MAX_LATE_AUX_REARMS) {
|
|
2980
|
+
rearmPendingAuxiliaryCoverage(pair);
|
|
1872
2981
|
lateAuxRearmed += 1;
|
|
2982
|
+
if (lateAuxStuckPairs.length < 20)
|
|
2983
|
+
lateAuxStuckPairs.push({
|
|
2984
|
+
filePath: pair.filePath,
|
|
2985
|
+
serverId: pair.serverId,
|
|
2986
|
+
});
|
|
2987
|
+
}
|
|
2988
|
+
else {
|
|
2989
|
+
if (isPendingAuxiliaryPastRearmTtl(pair))
|
|
2990
|
+
lateAuxExpired += 1;
|
|
2991
|
+
else
|
|
2992
|
+
lateAuxCeilingExhausted += 1;
|
|
1873
2993
|
}
|
|
1874
2994
|
continue;
|
|
1875
2995
|
}
|
|
2996
|
+
if (rawDiags.length === 0) {
|
|
2997
|
+
lateAuxCleanConfirmed += 1;
|
|
2998
|
+
continue;
|
|
2999
|
+
}
|
|
1876
3000
|
const converted = convertLspDiagnostics(rawDiags, lateAuxPath, {
|
|
1877
3001
|
tool: "lsp",
|
|
1878
3002
|
});
|
|
1879
|
-
if (converted.length === 0)
|
|
3003
|
+
if (converted.length === 0) {
|
|
3004
|
+
lateAuxMissing += rawDiags.length;
|
|
3005
|
+
lateAuxAnswered += 1;
|
|
1880
3006
|
continue;
|
|
3007
|
+
}
|
|
1881
3008
|
// Freshness kernel (#1634 gated surface): stat the cited file
|
|
1882
3009
|
// against the mark timestamp. Missing → drop (no remediation for
|
|
1883
3010
|
// a deleted file); mtime drifted past the mark → drop too, NOT
|
|
@@ -1897,16 +3024,30 @@ export async function handleTurnEnd(deps) {
|
|
|
1897
3024
|
lateAuxStale += gate.stale.length;
|
|
1898
3025
|
lateAuxMissing +=
|
|
1899
3026
|
converted.length - gate.live.length - gate.stale.length;
|
|
1900
|
-
if (gate.
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
3027
|
+
if (gate.live.length === 0) {
|
|
3028
|
+
if (gate.stale.length > 0) {
|
|
3029
|
+
// Stale findings mean the scan predates the last edit. Re-arm
|
|
3030
|
+
// with a refreshed baseline and carry the ceiling count.
|
|
3031
|
+
if (!isPendingAuxiliaryPastRearmTtl(pair) &&
|
|
3032
|
+
(pair.rearmCount ?? 0) < MAX_LATE_AUX_REARMS) {
|
|
3033
|
+
rearmPendingAuxiliaryCoverage(pair, Date.now(), true);
|
|
3034
|
+
lateAuxRearmed += 1;
|
|
3035
|
+
}
|
|
3036
|
+
else if (isPendingAuxiliaryPastRearmTtl(pair)) {
|
|
3037
|
+
lateAuxExpired += 1;
|
|
3038
|
+
}
|
|
3039
|
+
else {
|
|
3040
|
+
lateAuxCeilingExhausted += 1;
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
else {
|
|
3044
|
+
lateAuxAnswered += 1;
|
|
3045
|
+
}
|
|
1907
3046
|
continue;
|
|
3047
|
+
}
|
|
1908
3048
|
const lines = gate.live.map((f) => ` ${displayLateAuxPath}:${f.line}:${f.column} [${f.rule}] ${f.message}`);
|
|
1909
3049
|
lateAuxDelivered += gate.live.length;
|
|
3050
|
+
lateAuxAnswered += 1;
|
|
1910
3051
|
// @delivery-surface: runtime-turn:late-auxiliary-findings
|
|
1911
3052
|
advisoryParts.push(`🕐 Late auxiliary diagnostics (${pair.serverId} answered after its grace window):\n${lines.join("\n")}`);
|
|
1912
3053
|
}
|
|
@@ -1915,6 +3056,33 @@ export async function handleTurnEnd(deps) {
|
|
|
1915
3056
|
catch (err) {
|
|
1916
3057
|
dbg(`turn_end: late-auxiliary probe failed: ${err}`);
|
|
1917
3058
|
}
|
|
3059
|
+
// #2356: a demoted scanner that never gets replaced remains a coverage
|
|
3060
|
+
// gap. Re-raise it once when the existing bounded late-pair window closes,
|
|
3061
|
+
// preserving the server/file identity in both the ledger and latency row.
|
|
3062
|
+
for (const pair of lateAuxCoverageGapPairs) {
|
|
3063
|
+
const normalizedPairPath = normalizeMapKey(pair.filePath);
|
|
3064
|
+
const emitted = emitBounded("lsp_scanner_coverage_gap", `${pair.serverId}:${normalizedPairPath}`, {
|
|
3065
|
+
filePath: normalizedPairPath,
|
|
3066
|
+
durationMs: 0,
|
|
3067
|
+
metadata: {
|
|
3068
|
+
source: "late-auxiliary",
|
|
3069
|
+
serverIds: [pair.serverId],
|
|
3070
|
+
reason: "notify-stall-replacement-unavailable",
|
|
3071
|
+
reRaised: true,
|
|
3072
|
+
},
|
|
3073
|
+
}, {
|
|
3074
|
+
ledgerKind: "lsp-scanner-coverage-gap",
|
|
3075
|
+
reason: "notify-stall replacement was not available before late-coverage ceiling",
|
|
3076
|
+
capPerTurn: {
|
|
3077
|
+
limit: LATE_AUX_COVERAGE_GAP_DETAIL_CAP_PER_TURN,
|
|
3078
|
+
turnIndex: runtime.turnIndex,
|
|
3079
|
+
},
|
|
3080
|
+
});
|
|
3081
|
+
if (emitted)
|
|
3082
|
+
lateAuxCoverageGapDetailCount += 1;
|
|
3083
|
+
else
|
|
3084
|
+
lateAuxCoverageGapDropCount += 1;
|
|
3085
|
+
}
|
|
1918
3086
|
logLatency({
|
|
1919
3087
|
type: "phase",
|
|
1920
3088
|
toolName: "turn_end",
|
|
@@ -1923,12 +3091,24 @@ export async function handleTurnEnd(deps) {
|
|
|
1923
3091
|
durationMs: Date.now() - lateAuxStart,
|
|
1924
3092
|
metadata: {
|
|
1925
3093
|
pending: drainedPairs.length,
|
|
3094
|
+
pairCreated: drainedPairs.length + lateAuxCapEvicted,
|
|
3095
|
+
pendingAfter: pendingAuxiliaryCoverageSize(),
|
|
1926
3096
|
delivered: lateAuxDelivered,
|
|
1927
3097
|
stale: lateAuxStale,
|
|
1928
3098
|
missing: lateAuxMissing,
|
|
1929
3099
|
rearmed: lateAuxRearmed,
|
|
1930
3100
|
clientGone: lateAuxClientGone,
|
|
1931
3101
|
probeFailed: lateAuxProbeFailed,
|
|
3102
|
+
cleanConfirmed: lateAuxCleanConfirmed,
|
|
3103
|
+
expired: lateAuxExpired,
|
|
3104
|
+
ceilingExhausted: lateAuxCeilingExhausted,
|
|
3105
|
+
answered: lateAuxAnswered,
|
|
3106
|
+
notifyStallDemoted: lateAuxNotifyStallDemoted,
|
|
3107
|
+
coverageGapReRaised: lateAuxCoverageGapPairs.length,
|
|
3108
|
+
coverageGapReRaisedDetailed: lateAuxCoverageGapDetailCount,
|
|
3109
|
+
coverageGapReRaisedDropped: lateAuxCoverageGapDropCount,
|
|
3110
|
+
capEvicted: lateAuxCapEvicted,
|
|
3111
|
+
stuckPairs: lateAuxStuckPairs,
|
|
1932
3112
|
},
|
|
1933
3113
|
});
|
|
1934
3114
|
}
|
|
@@ -1945,7 +3125,7 @@ export async function handleTurnEnd(deps) {
|
|
|
1945
3125
|
const content = capTurnEndMessage(findingParts.join("\n\n"));
|
|
1946
3126
|
const signature = `${files
|
|
1947
3127
|
.slice()
|
|
1948
|
-
.sort((a, b) => a
|
|
3128
|
+
.sort((a, b) => compareOrdinal(a, b))
|
|
1949
3129
|
.join("|")}::${content}`;
|
|
1950
3130
|
const last = cacheManager.readCache("turn-end-findings-last", cwd);
|
|
1951
3131
|
if (last?.data?.signature === signature &&
|
|
@@ -1980,6 +3160,11 @@ export async function handleTurnEnd(deps) {
|
|
|
1980
3160
|
resetFormatService();
|
|
1981
3161
|
return;
|
|
1982
3162
|
}
|
|
3163
|
+
// #1950 fix-round F1: this turn's content is confirmed NOT suppressed —
|
|
3164
|
+
// it is about to reach the agent — so NOW commit the delivery-count
|
|
3165
|
+
// increments the per-blocker loop above only tentatively computed.
|
|
3166
|
+
for (const commit of pendingDependencyDriftDeliveries)
|
|
3167
|
+
commit();
|
|
1983
3168
|
const fileSeqByPath = {};
|
|
1984
3169
|
for (const [filePath, seq] of runtime.getFileSeqEntries()) {
|
|
1985
3170
|
fileSeqByPath[normalizeMapKey(path.resolve(filePath))] = seq;
|
|
@@ -2110,6 +3295,11 @@ export async function handleTurnEnd(deps) {
|
|
|
2110
3295
|
// answers that from latency.log even when the payload is empty, and
|
|
2111
3296
|
// the payload itself carries the retirement note when it is not.
|
|
2112
3297
|
demotedFindingsRetired,
|
|
3298
|
+
// #2275: the widget-footer store's own dependency-drift retirements —
|
|
3299
|
+
// a separate surface/counter from `demotedFindingsRetired` above,
|
|
3300
|
+
// since a widget retirement never touches `advisoryParts` or the
|
|
3301
|
+
// `turn-end-findings-last` suppression cache the block below clears.
|
|
3302
|
+
widgetDemotedFindingsRetired,
|
|
2113
3303
|
},
|
|
2114
3304
|
});
|
|
2115
3305
|
resetFormatService();
|