diff --git a/.github/workflows/edit-loop-gate.yml b/.github/workflows/edit-loop-gate.yml new file mode 100644 index 000000000..e53c67589 --- /dev/null +++ b/.github/workflows/edit-loop-gate.yml @@ -0,0 +1,142 @@ +name: Edit-loop reference gates + +on: + schedule: + - cron: '41 2 * * *' + workflow_dispatch: + inputs: + lane: + description: Reference lane (three consecutive runs per cell) + type: choice + options: [smoke, nightly] + default: smoke + seed: + description: Test-only regression; the selected row must fail + type: choice + options: [none, ack-wait, closed-read-gate] + default: none + +permissions: + contents: read + +# Requires a separately registered owned reference runner and reviewed cgroup/host bindings. +# Never cancel a run while its owned daemon still needs shutdown receipts. +concurrency: + group: edit-loop-reference + cancel-in-progress: false + +env: + CGO_ENABLED: '1' + CGO_CFLAGS: -O2 -g -fno-strict-aliasing + CGO_CXXFLAGS: -O2 -g -fno-strict-aliasing + GOTOOLCHAIN: local + +jobs: + smoke: + # This does not provision a runner or enable a repository variable. A + # missing/unreviewed host/config is unavailable, never a hosted substitute. + if: github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' && inputs.lane == 'smoke' && vars.EDIT_GATE_REFERENCE_SMOKE_ENABLED == 'true' + runs-on: [self-hosted, perf-reference-linux] + # Three separate <=15m smoke runs; each run retains its own wall receipt. + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Build the exact candidate and gate commands + env: + EDIT_GATE_SEED: ${{ inputs.seed }} + shell: bash + run: | + set -euo pipefail + tags=() + case "$EDIT_GATE_SEED" in + none) ;; + ack-wait|closed-read-gate) tags=(-tags=gortex_edit_gate_seed) ;; + *) exit 2 ;; + esac + go build -buildvcs=true "${tags[@]}" -o "$RUNNER_TEMP/edit-candidate" ./cmd/gortex + go build -buildvcs=true -o "$RUNNER_TEMP/edit-load" ./bench/load + go build -buildvcs=true -o "$RUNNER_TEMP/edit-check-budgets" ./bench/check-budgets + - name: Collect three smoke runs and enforce every blocking row + env: + EDIT_GATE_SEED: ${{ inputs.seed }} + EDIT_GATE_REFERENCE_SMOKE_CONFIG: ${{ vars.EDIT_GATE_REFERENCE_SMOKE_CONFIG }} + EDIT_GATE_ACK_SEED_CONFIG: ${{ vars.EDIT_GATE_ACK_SEED_CONFIG }} + EDIT_GATE_CLOSED_SEED_CONFIG: ${{ vars.EDIT_GATE_CLOSED_SEED_CONFIG }} + EXPECTED_CANDIDATE: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + case "$EDIT_GATE_SEED" in + none) config="$EDIT_GATE_REFERENCE_SMOKE_CONFIG" ;; + ack-wait) config="$EDIT_GATE_ACK_SEED_CONFIG" ;; + closed-read-gate) config="$EDIT_GATE_CLOSED_SEED_CONFIG" ;; + *) exit 2 ;; + esac + # Keep the reviewed config in place: its corpus/build paths are + # config-relative. Verify the requested fault matches actual tasks. + python3 - "$config" "$EDIT_GATE_SEED" <<'PY' + import json, sys + with open(sys.argv[1], encoding="utf-8") as source: + config = json.load(source) + expected = "" if sys.argv[2] == "none" else sys.argv[2] + if config.get("edit_gate_seed", "") != expected: + raise SystemExit("reviewed config disagrees with the requested fault") + PY + "$RUNNER_TEMP/edit-check-budgets" edit-collect \ + --config "$config" --require-lane smoke \ + --expected-candidate "$EXPECTED_CANDIDATE" \ + --candidate-binary "$RUNNER_TEMP/edit-candidate" \ + --load-binary "$RUNNER_TEMP/edit-load" \ + --out "$RUNNER_TEMP/edit-evidence-$GITHUB_RUN_ID" + - name: Preserve raw JSON, failed rows and owned stop receipts + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: edit-smoke-${{ inputs.seed }}-${{ github.run_id }} + path: ${{ runner.temp }}/edit-evidence-${{ github.run_id }}/ + retention-days: 90 + if-no-files-found: error + + nightly: + if: github.ref == 'refs/heads/main' && vars.EDIT_GATE_REFERENCE_NIGHTLY_ENABLED == 'true' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.lane == 'nightly')) + runs-on: [self-hosted, perf-reference-linux] + timeout-minutes: 1440 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Build the normal production candidate and gate commands + env: + EDIT_GATE_SEED: ${{ inputs.seed || 'none' }} + shell: bash + run: | + set -euo pipefail + test "$EDIT_GATE_SEED" = none + go build -buildvcs=true -o "$RUNNER_TEMP/edit-candidate" ./cmd/gortex + go build -buildvcs=true -o "$RUNNER_TEMP/edit-load" ./bench/load + go build -buildvcs=true -o "$RUNNER_TEMP/edit-check-budgets" ./bench/check-budgets + - name: Collect the full edit matrix and enforce every blocking row + env: + EDIT_GATE_REFERENCE_NIGHTLY_CONFIG: ${{ vars.EDIT_GATE_REFERENCE_NIGHTLY_CONFIG }} + EXPECTED_CANDIDATE: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + "$RUNNER_TEMP/edit-check-budgets" edit-collect \ + --config "$EDIT_GATE_REFERENCE_NIGHTLY_CONFIG" --require-lane nightly \ + --expected-candidate "$EXPECTED_CANDIDATE" \ + --candidate-binary "$RUNNER_TEMP/edit-candidate" \ + --load-binary "$RUNNER_TEMP/edit-load" \ + --out "$RUNNER_TEMP/edit-evidence-$GITHUB_RUN_ID" + - name: Preserve every matrix report, failure and owned stop receipt + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: edit-nightly-${{ github.run_id }} + path: ${{ runner.temp }}/edit-evidence-${{ github.run_id }}/ + retention-days: 90 + if-no-files-found: error diff --git a/bench/budgets.yaml b/bench/budgets.yaml index 9ea8b83aa..88b214d6c 100644 --- a/bench/budgets.yaml +++ b/bench/budgets.yaml @@ -11,73 +11,73 @@ allowances: - {"name": "dispatch_queue_memory", "owning_release": "v0.64.11", "kind": "additive", "value": 64, "unit": "MiB", "metrics": ["peak_memory_mib", "idle_memory_2s_mib", "worktree_memory_mib"], "scenarios": ["load-1", "load-8", "load-16", "load-32", "loadedit-1", "loadedit-4", "loadedit-8", "loadedit-16", "loadedit-32", "multirepoedit-6", "worktrees-4", "worktrees-8", "worktrees-16", "cold-gortex", "cold-six"]} budgets: # reference_linux / block - - {"metric": "edit_ack_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.15, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.25, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.15, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.25, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "edit_written_mib", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.15", "mode": "guard", "target": 15, "unit": "MiB/edit", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / block - {"metric": "relations_lag_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1.5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "derived_lag_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.2, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.35, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.2, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.35, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "edit_written_mib", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.15", "mode": "guard", "target": 15, "unit": "MiB/edit", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / block - {"metric": "relations_lag_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 3, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "derived_lag_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.25, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.4, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.25, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.4, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "edit_written_mib", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.15", "mode": "guard", "target": 15, "unit": "MiB/edit", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / block - {"metric": "relations_lag_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "derived_lag_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.35, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.55, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.35, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.55, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "edit_written_mib", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "unassigned", "mode": "guard", "target": null, "unit": "MiB/edit", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / block - - {"metric": "edit_ack_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.75, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.5, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.75, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "edit_written_mib", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "unassigned", "mode": "guard", "target": null, "unit": "MiB/edit", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / block - - {"metric": "edit_ack_p95", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.3, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.3, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} - {"metric": "relations_lag_p95", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 9, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} @@ -894,3 +894,53 @@ budgets: - {"metric": "sigaborts", "scenario": "warm-restart", "host_class": "hosted_arm_smoke", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["smoke"], "compare": "eq"} - {"metric": "oom_kills", "scenario": "warm-restart", "host_class": "hosted_arm_smoke", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["smoke"], "compare": "eq"} - {"metric": "force_kills", "scenario": "warm-restart", "host_class": "hosted_arm_smoke", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["smoke"], "compare": "eq"} + + # Dedicated edit-loop evidence; the frozen report-v1 lane cannot satisfy these. + - {"metric": "silent_stale_json", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "wal_p95_mib", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1007, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_p95_mib", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1059, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_p95_mib", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 960, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_p95_mib", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 760, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-4-largefile", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.6, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-4-largefile", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "readback_max", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "silent_stale_json", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "readback_max", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "readback_max", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "silent_stale_json", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "readback_max", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "silent_stale_json", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "readback_max", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "silent_stale_json", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "readback_max", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "silent_stale_json", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} diff --git a/bench/check-budgets/EDIT-LOOP.md b/bench/check-budgets/EDIT-LOOP.md new file mode 100644 index 000000000..98a6a82b0 --- /dev/null +++ b/bench/check-budgets/EDIT-LOOP.md @@ -0,0 +1,19 @@ +# Edit-loop gates + +`check-budgets edit-plan` and `edit-collect` select the edit-loop gate without changing the existing performance lanes or ordinary load plans. `edit-loop` evaluates retained reports. Unavailable evidence stays explicit and blocking; a successful plan is not a measurement. + +A smoke cell has at least 100 **total** logical edits. The opt-in `--edit-gate-modes` rotates JSON, TOON, compact text, facade-v1 and CLI probes over those edits, with explicit per-record mode and actual counts. At 100 edits the offered population is 20 per mode. The four-editor Poisson schedule, 15-second per-editor mean and JSON edit acknowledgements are retained. Coverage requires actual assigned edits, decoded answers and applicable tool witnesses, not just a planned count. + +Immediate reads use the same editor session after the ACK, with no sleep or barrier. The existing file oracle is reused alongside symbol source, file summary, symbol search and text search. Edit plus one-read latency ends at the first symbol-source read response; later readbacks do not extend that interval. Whole-run monotonic wall evidence includes owned preparation, readiness and normal cleanup. Each smoke run has a 15-minute bound; three independent runs of the same build must agree on every blocking row. Nightly runs retain their original offered schedule and cover 1/4/8/16/32 editors, six independent pinned repositories and the admitted large-file row. + +Six-repository edit plus one-read p95 is reported as `OBSERVATIONAL`, with a null budget and an explicit unbudgeted reason. No dedicated p95 target is specified for this cell. Its ACK target, universal readback maximum and other blocking rows remain enforced. Missing or duplicate observations remain unavailable. + +Compact freshness is the sole qualified unobservable number: its value stays null with a reason, never zero or PASS. It is nonblocking only after actual semantic witnesses for `read.summary`, `search.symbols`, `relations.callers` and `trace.call_chain`, assigned-edit counts and decoding checks pass. Compact `read.source`, `read.editing_context` and `search.text` are unsupported by the producer contract; each requires an explicit format-refusal record and actual decoded JSON fallback proof, with no compact coverage credit. Missing supported operations, missing fallback/refusal proof, stale content or decode failures remain blocking. The other four mode rows require explicit answer-local freshness observations. + +The reference workflow is `.github/workflows/edit-loop-gate.yml`. It runs trusted main code on a separately registered `perf-reference-linux` owned runner. It never substitutes a hosted runner for the reference Linux cgroup/8-CPU/16-GB contract. It does not register a runner or enable scheduling. Operators supply reviewed machine-bound configurations through `EDIT_GATE_REFERENCE_SMOKE_CONFIG` and `EDIT_GATE_REFERENCE_NIGHTLY_CONFIG` and explicitly enable the corresponding `EDIT_GATE_REFERENCE_SMOKE_ENABLED` / `EDIT_GATE_REFERENCE_NIGHTLY_ENABLED` variables. Configurations use the existing strict lane schema, reviewed pinned corpus manifests and exact clean candidate/main/release revisions. The nightly large-file manifest must prove the actual target has at least 10,000 admitted lines. Raw per-run JSON, budget tables, failures and owned shutdown receipts are retained as workflow artifacts. + +Two manual smoke faults are available: `ack-wait` and `closed-read-gate`. Their separately reviewed configurations are selected through `EDIT_GATE_ACK_SEED_CONFIG` and `EDIT_GATE_CLOSED_SEED_CONFIG`; `edit_gate_seed` must match the requested fault. The workflow builds the candidate with `gortex_edit_gate_seed` only for those fault runs. The load harness verifies the actual executable's build tag before allocating private state and forwards the known seed explicitly to its owned daemon. Ordinary builds compile the hooks out and ordinary private environments strip ambient seed variables. Faults cannot be selected through the ordinary collector, a nightly lane or a paired run. + +The ACK seed restores a real three-second wait on the admitted ACK path inside its existing accounting phase. The closed-gate seed takes the real urgent reclaim admission path and records actual pause/hold observations. Neither edits JSON results nor synthesizes counters. A seeded run must actually fail its intended comparator row; local switch tests alone do not satisfy the required failing-workflow evidence links. The workflow retains failed artifacts and does not tolerate a blocking comparator failure. + +No reference measurement, three-run stability result or seeded failing-workflow link is implied by installing this source. Those remain explicit acceptance evidence from an admitted run. diff --git a/bench/check-budgets/edit_loop.go b/bench/check-budgets/edit_loop.go new file mode 100644 index 000000000..2c0a6673b --- /dev/null +++ b/bench/check-budgets/edit_loop.go @@ -0,0 +1,612 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "slices" + "strings" + "text/tabwriter" +) + +type editLoopRow struct { + Metric string `json:"metric"` + Scenario string `json:"scenario"` + State string `json:"state"` + Value *float64 `json:"value"` + Budget *float64 `json:"budget"` + Unit string `json:"unit"` + Baseline *float64 `json:"baseline"` + Delta *float64 `json:"delta"` + Reason string `json:"reason"` +} + +type editLoopWire struct { + Plan struct { + Routes []struct { + ID string `json:"id"` + Repo string `json:"repo"` + Commit string `json:"commit"` + File string `json:"file"` + Kind string `json:"kind"` + Tool string `json:"tool"` + Language string `json:"language"` + Mode string `json:"mode"` + } `json:"routes"` + LinkedWorktrees int `json:"linked_worktrees"` + MeanNS *int64 `json:"mean_interarrival_ns"` + Arrival string `json:"arrival"` + GateModes bool `json:"gate_modes"` + Count int `json:"count"` + Editors int `json:"editors"` + Mode string `json:"mode"` + Session string `json:"session_model"` + Steps []struct { + Editor int `json:"editor"` + Route string `json:"route"` + Ordinal int `json:"ordinal"` + Mode string `json:"probe_mode"` + } `json:"steps"` + } `json:"plan"` + Edits []struct { + Editor int `json:"editor"` + Route string `json:"route"` + TargetLines *int `json:"admitted_target_lines"` + Ordinal int `json:"ordinal"` + Mode string `json:"probe_mode"` + Outcome string `json:"outcome"` + ACK metric `json:"t_ack_ms"` + OneRead metric `json:"t_gate_readback_ms"` + ACKExact *bool `json:"ack_readback_exact"` + Readbacks []struct { + Kind string `json:"kind"` + Correct *bool `json:"correct"` + Error string `json:"error"` + ObservationOnly bool `json:"observation_only"` + } `json:"readbacks"` + Errors []string `json:"errors"` + } `json:"edits"` + Modes map[string]struct { + Tools map[string]struct { + State string `json:"state"` + Reason string `json:"reason"` + CorrectEdits *int `json:"correct_edits"` + RefusedEdits *int `json:"refused_edits"` + FallbackAnswers *int `json:"fallback_answers"` + ObservedEdits *int `json:"observed_edits"` + Answers int `json:"probe_answers"` + DecodeFailures int `json:"decode_failures"` + ClaimsFresh *int `json:"claims_fresh"` + } `json:"tools"` + ObservedEdits *int `json:"observed_edits"` + ClaimsFresh *int `json:"claims_fresh"` + Answers int `json:"probe_answers"` + DecodeFailures int `json:"decode_failures"` + Observable bool `json:"claims_fresh_observable"` + Stale metric `json:"stale_claimed_fresh"` + } `json:"output_modes"` +} + +func editLoopCommand(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("check-budgets edit-loop", flag.ContinueOnError) + flags.SetOutput(stderr) + policyPath := flags.String("budgets", "bench/budgets.yaml", "budget policy") + inputsPath := flags.String("inputs", "", "pinned repeated edit-probe reports") + scenario := flags.String("scenario", "", "explicit edit-load cell") + output := flags.String("out", "", "new JSON evidence output") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 || *inputsPath == "" || *scenario == "" { + fmt.Fprintln(stderr, "--inputs and --scenario are required") + return 2 + } + data, err := os.ReadFile(*policyPath) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + policy, err := loadPolicy(data) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + var budgets []budget + for _, row := range policy.Budgets { + if row.HostClass == "reference_linux" && row.Scenario == *scenario && slices.Contains(row.Lane, "edit-smoke") { + budgets = append(budgets, row) + } + } + if len(budgets) == 0 { + fmt.Fprintln(stderr, "scenario has no declared edit-loop gate rows") + return 2 + } + dataset, loadErr := loadInputsChecked(*inputsPath, validateEditLoopReport) + rows := make([]editLoopRow, 0, len(budgets)) + pass := true + for _, b := range budgets { + row := editLoopRow{Metric: b.Metric, Scenario: b.Scenario, State: "UNAVAILABLE", Budget: b.Target, Unit: b.Unit} + if loadErr != nil { + row.Reason = loadErr.Error() + } else { + row = assessEditLoopRow(b, dataset) + } + if editLoopRowBlocks(row) { + pass = false + } + rows = append(rows, row) + } + if *scenario == "multirepoedit-6" { + row := assessSixRepoReadObservation(dataset, loadErr) + rows = append(rows, row) + if editLoopRowBlocks(row) { + pass = false + } + } + writer := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(writer, "scenario\tmetric\tvalue\tbudget\tunit\tbaseline\tdelta\tstate\treason") + for _, row := range rows { + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", row.Scenario, row.Metric, editLoopNumber(row.Value), editLoopNumber(row.Budget), row.Unit, editLoopNumber(row.Baseline), editLoopNumber(row.Delta), row.State, row.Reason) + } + if err := writer.Flush(); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + if *output != "" { + if err := writeNewJSON(*output, struct { + Pass bool `json:"pass"` + Rows []editLoopRow `json:"rows"` + }{pass, rows}); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + } + if !pass { + return 1 + } + return 0 +} + +func editLoopNumber(value *float64) string { + if value == nil { + return "unavailable" + } + return fmt.Sprintf("%.6g", *value) +} + +func validateEditLoopReport(config inputs, input runInput, r report) error { + if r.Version != 1 || !r.Complete || r.RunID == "" || r.Revision != map[string]string{"candidate": config.CandidateRevision, "main": config.MainRevision, "tag": config.TagRevision}[input.Role] || r.Profile != "agent" || !planPattern.MatchString(r.Plan) || len(r.Errors) != 0 || len(r.Phases) != 1 { + return errors.New("incomplete or unpinned edit-loop report") + } + if err := validateHost(input.HostClass, config.Hosts[input.HostClass], r); err != nil { + return err + } + for _, name := range []string{"panic_lines", "sigabrt_lines", "oom_log_lines"} { + if r.Crashes[name] == nil || *r.Crashes[name] != 0 { + return errors.New("missing or nonzero safety census") + } + } + phase := r.Phases[0] + if phase.Name != "edit-and-probe" || phase.SessionModel == "" || len(phase.Errors) != 0 || !finiteNonnegative(phase.Duration) || phase.Duration <= 0 || input.Scenario == "loadedit-4" && phase.Duration > 900 { + return errors.New("edit-loop phase incomplete or exceeds the 15-minute gate") + } + var wall struct { + Clock string `json:"clock"` + NS *int64 `json:"wall_ns"` + Complete bool `json:"complete"` + } + if json.Unmarshal(phase.Gate["edit_loop_wall"], &wall) != nil || wall.Clock != "monotonic" || wall.NS == nil || *wall.NS <= 0 || input.Scenario == "loadedit-4" && *wall.NS > 900000000000 || float64(*wall.NS) < phase.Duration*1e9 || !wall.Complete { + return errors.New("missing, incomplete or over-15-minute whole-run wall observation") + } + if _, coupled := phase.Gate["paired_measurement_window"]; coupled { + return errors.New("paired child cannot supply standalone gate evidence") + } + if forced, err := number(phase.Metrics["force_killed"], "boolean"); err != nil || forced != 0 { + return errors.New("missing clean daemon-stop evidence") + } + var workload struct { + Scenario string `json:"scenario"` + Agents int `json:"agents"` + Editors int `json:"edit_agents"` + Session string `json:"session_model"` + } + if err := json.Unmarshal(r.Workload, &workload); err != nil { + return err + } + expected := scenarioSpecs[input.Scenario].editors + if input.Scenario == "loadedit-4-largefile" { + expected = 4 + } + session := "fixed_editor_per_ordinal_plus_one_separate_prober_per_edit" + if expected == 1 { + session = "one_editor_plus_one_separate_prober_per_edit" + } + if workload.Scenario != "edit-and-probe" || workload.Agents != 0 || workload.Editors != expected || workload.Session != session || phase.SessionModel != session { + return errors.New("incorrect edit-loop workload population or session model") + } + wire, err := decodeEditLoopWire(phase) + if err != nil { + return err + } + editors := wire.Plan.Editors + if editors == 0 && wire.Plan.Session == "one_editor_plus_one_separate_prober_per_edit" { + editors = 1 + } + if editors != expected || wire.Plan.Session != session { + return errors.New("plan editor population differs from workload") + } + if input.Scenario == "loadedit-4-largefile" { + for _, record := range wire.Edits { + if record.TargetLines == nil || *record.TargetLines < 10000 { + return errors.New("large-file row lacks actual >=10000-line admitted target evidence") + } + } + } + if input.Scenario == "multirepoedit-6" { + if len(wire.Plan.Routes) != 6 || len(r.Corpus) != 6 || wire.Plan.LinkedWorktrees != 0 { + return errors.New("six-repository cell requires six independent pinned corpus routes") + } + corpora := map[string]string{} + for _, corpus := range r.Corpus { + label, _ := corpus["label"].(string) + commit, _ := corpus["commit"].(string) + if label == "" || corpora[label] != "" || !revisionPattern.MatchString(commit) { + return errors.New("invalid six-repository corpus pins") + } + corpora[label] = commit + } + seen := map[string]bool{} + for _, route := range wire.Plan.Routes { + if route.ID == "" || seen[route.ID] || route.Repo != route.ID || corpora[route.ID] != route.Commit || route.File == "" || route.Kind != "add_intrafile_call" || route.Tool != "edit.file" || route.Language != "go" || route.Mode != "json" { + return errors.New("six-repository route differs from admitted corpus pin") + } + seen[route.ID] = true + } + used := map[string]bool{} + for i, record := range wire.Edits { + step := wire.Plan.Steps[i] + if record.Editor < 0 || record.Editor >= 6 || step.Editor != record.Editor || record.Route != step.Route || record.Route != wire.Plan.Routes[record.Editor].ID { + return errors.New("edit borrowed a foreign six-repository editor/route") + } + used[record.Route] = true + } + if len(used) != 6 { + return errors.New("six-repository cell did not actually edit all routes") + } + } + return nil +} + +func decodeEditLoopWire(phase phase) (editLoopWire, error) { + var wire editLoopWire + if err := json.Unmarshal(phase.EditProbe, &wire); err != nil { + return wire, fmt.Errorf("missing edit-probe evidence: %w", err) + } + if wire.Plan.MeanNS == nil || *wire.Plan.MeanNS != 15000000000 || wire.Plan.Arrival != "precomputed_open_loop_poisson" { + return wire, errors.New("gate changed the offered 15-second-per-editor open-loop schedule") + } + if !wire.Plan.GateModes || wire.Plan.Count < 100 || len(wire.Edits) != wire.Plan.Count || len(wire.Plan.Steps) != wire.Plan.Count || wire.Plan.Mode != "json" { + return wire, errors.New("gate needs the complete >=100-total JSON-ACK/five-mode denominator") + } + modes := []string{"json", "toon", "compact", "facade", "cli"} + for i, record := range wire.Edits { + if record.Ordinal != i || wire.Plan.Steps[i].Ordinal != i || record.Mode != modes[i%5] || wire.Plan.Steps[i].Mode != record.Mode || record.Outcome != "complete" || len(record.Errors) != 0 { + return wire, errors.New("missing, duplicate, failed or incorrectly routed gate edit") + } + } + return wire, nil +} + +func assessEditLoopRow(b budget, dataset dataset) editLoopRow { + row := editLoopRow{Metric: b.Metric, Scenario: b.Scenario, State: "UNAVAILABLE", Budget: b.Target, Unit: b.Unit, Baseline: editLoopBaseline(b, dataset)} + if b.Metric == "silent_stale_compact" { + return assessCompactEditLoopRow(row, b, dataset) + } + values := make([]float64, 0, dataset.config.repeats(b.Scenario)) + verdict := -1 + for repeat := 1; repeat <= dataset.config.repeats(b.Scenario); repeat++ { + found := false + for _, observation := range dataset.runs { + if observation.input.Role != "candidate" || observation.input.HostClass != b.HostClass || observation.input.Scenario != b.Scenario || observation.input.Repeat != repeat { + continue + } + found = true + value, err := measureEditLoopRow(observation.data.Phases[0], b.Metric) + if err != nil { + row.Reason = err.Error() + return row + } + current := 0 + if editLoopCompare(value, *b.Target, b.Compare) { + current = 1 + } + if verdict != -1 && verdict != current { + row.State = "FAIL" + row.Reason = "same-build repeat verdicts disagree" + return row + } + verdict = current + values = append(values, value) + } + if !found { + row.Reason = "missing candidate repeat" + return row + } + } + if len(values) == 0 { + row.Reason = "no actual observations" + return row + } + value := slices.Max(values) + if b.Compare == "ge" { + value = slices.Min(values) + } + row.Value = &value + if row.Baseline != nil { + delta := value - *row.Baseline + row.Delta = &delta + } + row.State = "FAIL" + if verdict == 1 { + row.State = "PASS" + } + return row +} + +// The WAL policy explicitly adopts each shipped release peak as the p95 +// baseline. Other rows use actual matched shipped reports when available; +// the historical development-machine ACK numbers are not reference-host data. +func editLoopBaseline(b budget, d dataset) *float64 { + if b.Metric == "wal_p95_mib" { + if value, exists := map[string]float64{"loadedit-1": 1007, "loadedit-4": 1059, "loadedit-8": 960, "multirepoedit-6": 760}[b.Scenario]; exists { + return &value + } + } + values := make([]float64, 0, d.config.repeats(b.Scenario)) + for repeat := 1; repeat <= d.config.repeats(b.Scenario); repeat++ { + found := false + for _, observation := range d.runs { + if observation.input.Role != "tag" || observation.input.HostClass != b.HostClass || observation.input.Scenario != b.Scenario || observation.input.Repeat != repeat { + continue + } + value, err := measureEditLoopRow(observation.data.Phases[0], b.Metric) + if err != nil { + return nil + } + values = append(values, value) + found = true + } + if !found { + return nil + } + } + value, err := median(values) + if err != nil { + return nil + } + return &value +} + +// Only the explicitly accepted compact metadata contract is qualified. Every +// supported compact tool still proves decoded post-ACK content for every +// assigned edit; unsupported operations prove the harness's format refusal +// and actual JSON fallback without receiving any compact-format credit. +func assessCompactEditLoopRow(row editLoopRow, b budget, d dataset) editLoopRow { + if d.config.repeats(b.Scenario) < 3 { + row.Reason = "at least three actual repeats required" + return row + } + for repeat := 1; repeat <= d.config.repeats(b.Scenario); repeat++ { + found := false + for _, observation := range d.runs { + if observation.input.Role != "candidate" || observation.input.HostClass != b.HostClass || observation.input.Scenario != b.Scenario || observation.input.Repeat != repeat { + continue + } + found = true + wire, err := decodeEditLoopWire(observation.data.Phases[0]) + if err != nil { + row.Reason = err.Error() + return row + } + expected := 0 + for _, record := range wire.Edits { + if record.Mode == "compact" { + expected++ + } + } + mode, exists := wire.Modes["compact"] + if !exists || expected == 0 || mode.ObservedEdits == nil || *mode.ObservedEdits != expected || mode.Answers < expected || mode.DecodeFailures != 0 || mode.Observable || mode.ClaimsFresh == nil || *mode.ClaimsFresh != 0 || mode.Stale.Value != nil || mode.Stale.Reason == "" { + row.Reason = "compact actual assigned-edit/decoder proof missing or contradicts known unobservable contract" + return row + } + for _, name := range []string{"read.summary", "search.symbols", "relations.callers", "trace.call_chain"} { + tool, exists := mode.Tools[name] + if !exists || tool.ObservedEdits == nil || *tool.ObservedEdits != expected || tool.CorrectEdits == nil || *tool.CorrectEdits != expected || tool.Answers < expected || tool.DecodeFailures != 0 { + row.Reason = "compact supported tool lacks post-ACK semantic witness: " + name + return row + } + } + for _, name := range []string{"read.source", "read.editing_context", "search.text"} { + tool, exists := mode.Tools[name] + if !exists || tool.State != "UNSUPPORTED" || tool.Reason == "" || tool.RefusedEdits == nil || *tool.RefusedEdits != expected || tool.FallbackAnswers == nil || *tool.FallbackAnswers < expected || tool.ObservedEdits == nil || *tool.ObservedEdits != 0 || tool.DecodeFailures != 0 { + row.Reason = "compact unsupported operation lacks explicit format refusal and actual fallback proof: " + name + return row + } + } + } + if !found { + row.Reason = "missing candidate repeat" + return row + } + } + row.State = "UNOBSERVABLE" + row.Reason = "compact freshness metadata is unobservable under the accepted contract; actual supported content/tool and unsupported-refusal proofs passed; no numeric silent-stale claim" + return row +} + +func editLoopRowBlocks(row editLoopRow) bool { + if row.Scenario == "multirepoedit-6" && row.Metric == "readback_p95" && row.State == "OBSERVATIONAL" && row.Budget == nil && row.Value != nil && finiteNonnegative(*row.Value) && row.Unit == "s" { + return false + } + if row.Metric == "silent_stale_compact" && row.State == "UNOBSERVABLE" && row.Value == nil { + return false + } + return row.State != "PASS" +} + +func editLoopCompare(value, target float64, compare string) bool { + switch compare { + case "le": + return value <= target + case "lt": + return value < target + case "ge": + return value >= target + case "eq": + return value == target + } + return false +} + +func measureEditLoopRow(phase phase, name string) (float64, error) { + wire, err := decodeEditLoopWire(phase) + if err != nil { + return 0, err + } + switch name { + case "wal_p95_mib", "wal_peak_mib": + key := "p95_wal_bytes" + if name == "wal_peak_mib" { + key = "peak_wal_bytes" + } + value, err := number(phase.Metrics[key], "bytes") + return value / (1024 * 1024), err + case "read_gate_closures": + return number(phase.Metrics["reclaim_gate_closures"], "closures") + case "reclaim_hold_p95": + value, err := number(phase.Metrics["reclaim_writer_hold_p95_ms"], "ms") + return value / 1000, err + } + if mode, ok := strings.CutPrefix(name, "silent_stale_"); ok { + coverage, exists := wire.Modes[mode] + expected := 0 + for _, record := range wire.Edits { + if record.Mode == mode { + expected++ + } + } + if !exists || expected == 0 || coverage.ObservedEdits == nil || *coverage.ObservedEdits != expected || coverage.ClaimsFresh == nil || *coverage.ClaimsFresh <= 0 || coverage.Answers < expected || coverage.DecodeFailures != 0 || !coverage.Observable { + return 0, errors.New("missing or unobservable actual output-mode coverage; absent freshness is never zero-stale") + } + for _, name := range []string{"read.source", "read.summary", "read.editing_context", "search.symbols", "search.text", "relations.callers", "trace.call_chain"} { + tool, exists := coverage.Tools[name] + if !exists || tool.ObservedEdits == nil || *tool.ObservedEdits != expected || tool.Answers < expected || tool.DecodeFailures != 0 || tool.ClaimsFresh == nil || *tool.ClaimsFresh <= 0 { + return 0, fmt.Errorf("missing actual %s/%s coverage or explicit freshness claims", mode, name) + } + } + return number(coverage.Stale, "answers") + } + var values []float64 + correct, total := 0, 0 + for _, record := range wire.Edits { + if name == "edit_ack_p95" || name == "edit_ack_max" { + if record.ACKExact == nil || !*record.ACKExact { + return 0, errors.New("ACK lacks exact readback proof") + } + value, err := number(record.ACK, "ms") + if err != nil { + return 0, err + } + values = append(values, value/1000) + } else if name == "readback_p95" || name == "readback_max" { + value, err := number(record.OneRead, "ms") + if err != nil { + return 0, err + } + values = append(values, value/1000) + } else if name == "readback_correct" { + seen := map[string]bool{} + for _, read := range record.Readbacks { + if read.ObservationOnly { + continue + } + if seen[read.Kind] { + return 0, errors.New("duplicate immediate readback") + } + seen[read.Kind] = true + if read.Correct == nil { + return 0, errors.New("immediate readback correctness unavailable") + } + total++ + if *read.Correct && read.Error == "" { + correct++ + } + } + for _, kind := range []string{"file", "source", "summary", "search_visible", "text"} { + if !seen[kind] { + return 0, fmt.Errorf("missing immediate %s readback", kind) + } + } + } + } + if name == "readback_correct" { + if total == 0 { + return 0, errors.New("no readbacks") + } + return float64(correct) / float64(total), nil + } + if len(values) == 0 { + return 0, errors.New("required edit-loop measure unavailable") + } + slices.Sort(values) + if strings.HasSuffix(name, "_max") { + return values[len(values)-1], nil + } + return values[(95*len(values)+99)/100-1], nil +} + +// This population has no dedicated p95 target in the accepted contract. Retain +// its actual value without borrowing a single-repository limit or a PASS. +func assessSixRepoReadObservation(d dataset, loadErr error) editLoopRow { + row := editLoopRow{Scenario: "multirepoedit-6", Metric: "readback_p95", State: "UNAVAILABLE", Unit: "s"} + if loadErr != nil { + row.Reason = loadErr.Error() + return row + } + values := []float64{} + for repeat := 1; repeat <= d.config.repeats(row.Scenario); repeat++ { + found := false + for _, sample := range d.runs { + if sample.input.Role != "candidate" || sample.input.HostClass != "reference_linux" || sample.input.Scenario != row.Scenario || sample.input.Repeat != repeat { + continue + } + if found { + row.Reason = "duplicate candidate repeat" + return row + } + found = true + value, err := measureEditLoopRow(sample.data.Phases[0], row.Metric) + if err != nil { + row.Reason = err.Error() + return row + } + values = append(values, value) + } + if !found { + row.Reason = "missing candidate repeat" + return row + } + } + if len(values) == 0 { + row.Reason = "no actual observations" + return row + } + value := slices.Max(values) + row.Value = &value + row.State = "OBSERVATIONAL" + row.Reason = "unbudgeted: no dedicated six-repository p95 target; universal readback max remains blocking" + return row +} diff --git a/bench/check-budgets/edit_loop_command_test.go b/bench/check-budgets/edit_loop_command_test.go new file mode 100644 index 000000000..f7dc3461f --- /dev/null +++ b/bench/check-budgets/edit_loop_command_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEditLoopGateRetainsEveryRowWhenEvidenceIsMissing(t *testing.T) { + var out, diagnostics bytes.Buffer + code := run([]string{"edit-loop", "--budgets", "../budgets.yaml", "--scenario", "loadedit-4", "--inputs", filepath.Join(t.TempDir(), "absent-inputs.json")}, &out, &diagnostics) + if code != 1 { + t.Fatalf("missing gate evidence exit=%d, want1; diagnostics=%s", code, diagnostics.String()) + } + text := out.String() + for _, row := range []string{"edit_ack_p95", "edit_ack_max", "readback_p95", "readback_max", "readback_correct", "silent_stale_json", "silent_stale_toon", "silent_stale_compact", "silent_stale_facade", "silent_stale_cli", "wal_p95_mib", "wal_peak_mib", "reclaim_hold_p95", "read_gate_closures"} { + if !strings.Contains(text, row) { + t.Errorf("missing evidence hid mandatory row %s: %s", row, text) + } + } + if !strings.Contains(text, "UNAVAILABLE") || !strings.Contains(strings.ToLower(text), "baseline") || !strings.Contains(strings.ToLower(text), "delta") { + t.Errorf("gate lost unavailable/baseline/delta interpretation: %s", text) + } + if strings.Contains(text, "\tPASS\t") { + t.Fatal("unobserved row became a numeric pass") + } +} + +func TestEditLoopCommandRetainsActualRowsAndSeededBreaches(t *testing.T) { + for _, variant := range []string{"complete", "ack_wait", "closed_gate", "repeat_disagreement"} { + t.Run(variant, func(t *testing.T) { + dir := t.TempDir() + config := inputs{Version: 1, Repeats: 3, CandidateRevision: strings.Repeat("a", 40), MainRevision: strings.Repeat("d", 40), TagRevision: shippedRevision, Hosts: map[string]string{"reference_linux": "synthetic-host"}} + write := func(path string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, path), data, 0o600); err != nil { + t.Fatal(err) + } + } + for repeat := 1; repeat <= 3; repeat++ { + r := editLoopEvidenceFixture(t) + r.RunID = fmt.Sprintf("synthetic-edit-%d", repeat) + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + compact := raw["output_modes"].(map[string]any)["compact"].(map[string]any) + compact["claims_fresh_observable"], compact["claims_fresh"] = false, 0 + compact["stale_claimed_fresh"] = map[string]any{"value": nil, "unit": "answers", "reason": "compact freshness metadata unobservable"} + tools := compact["tools"].(map[string]any) + for name, value := range tools { + tool := value.(map[string]any) + if name == "read.source" || name == "read.editing_context" || name == "search.text" { + tool["state"], tool["reason"], tool["refused_edits"], tool["fallback_answers"], tool["observed_edits"] = "UNSUPPORTED", "harness compact-format refusal; actual JSON fallback retained", 20, 20, 0 + } else { + tool["correct_edits"] = 20 + } + } + + if variant == "ack_wait" || variant == "repeat_disagreement" && repeat == 3 { + for _, value := range raw["edits"].([]any) { + value.(map[string]any)["t_ack_ms"] = map[string]any{"value": 3000, "unit": "ms", "reason": ""} + } + } + if variant == "closed_gate" { + r.Phases[0].Metrics["reclaim_gate_closures"] = metric{Value: pointer(1), Unit: "closures"} + } + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + name := fmt.Sprintf("repeat-%d.json", repeat) + write(name, r) + config.Runs = append(config.Runs, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-4", Repeat: repeat, Report: name}) + } + write("inputs.json", config) + var out, diagnostics bytes.Buffer + if exit := run([]string{"edit-loop", "--budgets", "../budgets.yaml", "--scenario", "loadedit-4", "--inputs", filepath.Join(dir, "inputs.json"), "--out", filepath.Join(dir, "result.json")}, &out, &diagnostics); exit != map[bool]int{true: 0, false: 1}[variant == "complete"] { + t.Fatalf("qualified compact cell must stay nonnumeric while blocking breaches fail, exit%d: %s", exit, diagnostics.String()) + } + data, err := os.ReadFile(filepath.Join(dir, "result.json")) + if err != nil { + t.Fatal(err) + } + var result struct { + Rows []editLoopRow `json:"rows"` + } + if err := json.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + rows := map[string]editLoopRow{} + for _, row := range result.Rows { + rows[row.Metric] = row + } + if rows["silent_stale_compact"].State != "UNOBSERVABLE" || rows["silent_stale_compact"].Value != nil { + t.Error("unobservable compact freshness became numeric pass") + } + if row := rows["wal_p95_mib"]; row.State != "PASS" || row.Value == nil || *row.Value != 32 || row.Baseline == nil || *row.Baseline != 1059 || row.Delta == nil || *row.Delta != -1027 { + t.Errorf("fixed shipped WAL baseline/delta missing: %+v", row) + } + wantAck, wantGate := "PASS", "PASS" + if variant == "ack_wait" || variant == "repeat_disagreement" { + wantAck = "FAIL" + } + if variant == "closed_gate" { + wantGate = "FAIL" + } + if row := rows["edit_ack_p95"]; row.State != wantAck { + t.Errorf("ACK row %+v, want%s; %s", row, wantAck, out.String()) + } + if row := rows["read_gate_closures"]; row.State != wantGate { + t.Errorf("read gate row %+v, want%s", row, wantGate) + } + if rows["silent_stale_json"].State != "PASS" || rows["readback_correct"].State != "PASS" { + t.Errorf("actual complete witnesses lost: %s", out.String()) + } + }) + } +} diff --git a/bench/check-budgets/edit_loop_evidence_test.go b/bench/check-budgets/edit_loop_evidence_test.go new file mode 100644 index 000000000..1188322f3 --- /dev/null +++ b/bench/check-budgets/edit_loop_evidence_test.go @@ -0,0 +1,342 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +func editLoopEvidenceFixture(t *testing.T) report { + t.Helper() + raw := syntheticReport() + workload := raw["workload"].(map[string]any) + workload["scenario"], workload["agents"], workload["edit_agents"], workload["session_model"] = "edit-and-probe", 0, 4, "fixed_editor_per_ordinal_plus_one_separate_prober_per_edit" + phase := raw["phases"].([]any)[0].(map[string]any) + phase["name"], phase["session_model"], phase["duration_seconds"] = "edit-and-probe", workload["session_model"], 600 + phase["gate"].(map[string]any)["edit_loop_wall"] = map[string]any{"clock": "monotonic", "wall_ns": int64(600000000000), "complete": true} + value := func(n float64, unit string) map[string]any { + return map[string]any{"value": n, "unit": unit, "reason": ""} + } + metrics := phase["metrics"].(map[string]any) + metrics["p95_wal_bytes"], metrics["peak_wal_bytes"] = value(32*1024*1024, "bytes"), value(64*1024*1024, "bytes") + metrics["reclaim_gate_closures"], metrics["reclaim_writer_holds"], metrics["reclaim_writer_hold_p95_ms"] = value(0, "closures"), value(4, "holds"), value(10, "ms") + modes := []string{"json", "toon", "compact", "facade", "cli"} + steps, records := make([]any, 100), make([]any, 100) + for i := range 100 { + mode := modes[i%5] + steps[i] = map[string]any{"ordinal": i, "probe_mode": mode, "editor": i % 4} + reads := make([]any, 0, 5) + for _, kind := range []string{"file", "source", "summary", "search_visible", "text"} { + reads = append(reads, map[string]any{"kind": kind, "correct": true, "error": "", "observation_only": false}) + } + records[i] = map[string]any{"ordinal": i, "probe_mode": mode, "outcome": "complete", "t_ack_ms": value(50, "ms"), "ack_readback_exact": true, "t_gate_readback_ms": value(100, "ms"), "readbacks": reads, "errors": []string{}} + } + coverage := map[string]any{} + for _, mode := range modes { + tools := map[string]any{} + for _, tool := range []string{"read.source", "read.summary", "read.editing_context", "search.symbols", "search.text", "relations.callers", "trace.call_chain"} { + tools[tool] = map[string]any{"probe_answers": 20, "observed_edits": 20, "decode_failures": 0, "claims_fresh": 20} + } + coverage[mode] = map[string]any{"tools": tools, "probe_answers": 140, "observed_edits": 20, "decode_failures": 0, "claims_fresh": 140, "claims_fresh_observable": true, "stale_claimed_fresh": value(0, "answers")} + } + phase["edit_probe"] = map[string]any{"plan": map[string]any{"gate_modes": true, "count": 100, "editors": 4, "mode": "json", "arrival": "precomputed_open_loop_poisson", "mean_interarrival_ns": int64(15000000000), "session_model": workload["session_model"], "steps": steps}, "edits": records, "output_modes": coverage} + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + result, err := decodeReport(data) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestEditLoopMeasurementsUseActualRecordsAndCounters(t *testing.T) { + r := editLoopEvidenceFixture(t) + config := inputs{CandidateRevision: strings.Repeat("a", 40), Hosts: map[string]string{"reference_linux": "synthetic-host"}} + if err := validateEditLoopReport(config, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-4"}, r); err != nil { + t.Fatal(err) + } + for name, want := range map[string]float64{"edit_ack_p95": .05, "edit_ack_max": .05, "readback_p95": .1, "readback_max": .1, "readback_correct": 1, "wal_p95_mib": 32, "wal_peak_mib": 64, "read_gate_closures": 0, "reclaim_hold_p95": .01, "silent_stale_json": 0} { + got, err := measureEditLoopRow(r.Phases[0], name) + if err != nil || got != want { + t.Errorf("%s=%v error%v, want%v", name, got, err, want) + } + } +} + +func TestEditLoopEvidenceRejectsWrongPhaseCadenceAndWall(t *testing.T) { + config := inputs{CandidateRevision: strings.Repeat("a", 40), Hosts: map[string]string{"reference_linux": "synthetic-host"}} + for _, variant := range []string{"phase_name", "coupled_phase", "cadence", "wall_missing", "wall_over15min"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + switch variant { + case "phase_name": + r.Phases[0].Name = "read" + case "coupled_phase": + r.Phases[0].SessionModel = "coupled_native" + case "wall_missing": + delete(r.Phases[0].Gate, "edit_loop_wall") + case "wall_over15min": + r.Phases[0].Gate["edit_loop_wall"] = json.RawMessage(`{"clock":"monotonic","wall_ns":901000000000,"complete":true}`) + case "cadence": + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + raw["plan"].(map[string]any)["mean_interarrival_ns"] = 1000000 + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + } + if err := validateEditLoopReport(config, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-4"}, r); err == nil { + t.Fatal("invalid timing/workload evidence accepted") + } + }) + } +} + +func TestEditLoopModeCoverageCannotBorrowAnotherEditOrMissingFreshClaims(t *testing.T) { + for _, variant := range []string{"one_edit", "no_claims", "missing_mode", "decode_failed", "unobservable"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + modes := raw["output_modes"].(map[string]any) + coverage := modes["json"].(map[string]any) + switch variant { + case "one_edit": + coverage["observed_edits"] = 1 + case "no_claims": + coverage["claims_fresh"] = 0 + case "missing_mode": + delete(modes, "json") + case "decode_failed": + coverage["decode_failures"] = 1 + case "unobservable": + coverage["claims_fresh_observable"] = false + } + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + if _, err := measureEditLoopRow(r.Phases[0], "silent_stale_json"); err == nil { + t.Fatal("missing/refused per-mode evidence became zero-stale") + } + }) + } +} + +func TestEditLoopModeCoverageRequiresEveryApplicableTool(t *testing.T) { + for _, variant := range []string{"missing_tool", "one_edit", "decode_failed", "missing_fresh_claim"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + tools := raw["output_modes"].(map[string]any)["json"].(map[string]any)["tools"].(map[string]any) + tool := tools["read.summary"].(map[string]any) + switch variant { + case "missing_tool": + delete(tools, "read.summary") + case "one_edit": + tool["observed_edits"] = 1 + case "decode_failed": + tool["decode_failures"] = 1 + case "missing_fresh_claim": + delete(tool, "claims_fresh") + } + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + if _, err := measureEditLoopRow(r.Phases[0], "silent_stale_json"); err == nil { + t.Fatal("another tool lent coverage or freshness credit to an unproved tool") + } + }) + } +} + +func TestEditLoopNightlySingleEditorRetainsItsOfferedSchedule(t *testing.T) { + r := editLoopEvidenceFixture(t) + var workload, raw map[string]any + if err := json.Unmarshal(r.Workload, &workload); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + session := "one_editor_plus_one_separate_prober_per_edit" + workload["edit_agents"], workload["session_model"] = 1, session + plan := raw["plan"].(map[string]any) + plan["editors"], plan["session_model"] = 1, session + var err error + r.Workload, err = json.Marshal(workload) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe, err = json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].SessionModel, r.Phases[0].Duration = session, 1500 + r.Phases[0].Gate["edit_loop_wall"] = json.RawMessage(`{"clock":"monotonic","wall_ns":1500000000000,"complete":true}`) + config := inputs{CandidateRevision: strings.Repeat("a", 40), Hosts: map[string]string{"reference_linux": "synthetic-host"}} + if err := validateEditLoopReport(config, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-1"}, r); err != nil { + t.Fatalf("nightly's 100 edits at the original 15-second single-editor cadence got an invented smoke deadline: %v", err) + } +} + +func TestEditLoopCompactQualificationNeverWaivesMissingProof(t *testing.T) { + for _, variant := range []string{"complete", "missing_supported_tool", "one_correct_edit", "decode_failure", "missing_refusal", "missing_fallback", "untested_unsupported"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + mode := raw["output_modes"].(map[string]any)["compact"].(map[string]any) + mode["claims_fresh_observable"], mode["claims_fresh"] = false, 0 + mode["stale_claimed_fresh"] = map[string]any{"value": nil, "unit": "answers", "reason": "compact freshness metadata unobservable"} + tools := mode["tools"].(map[string]any) + for name, value := range tools { + tool := value.(map[string]any) + if name == "read.source" || name == "read.editing_context" || name == "search.text" { + tool["state"], tool["reason"], tool["refused_edits"], tool["fallback_answers"], tool["observed_edits"] = "UNSUPPORTED", "harness compact-format refusal; actual JSON fallback retained", 20, 20, 0 + } else { + tool["correct_edits"] = 20 + } + } + switch variant { + case "missing_supported_tool": + delete(tools, "read.summary") + case "one_correct_edit": + tools["read.summary"].(map[string]any)["correct_edits"] = 1 + case "decode_failure": + tools["read.summary"].(map[string]any)["decode_failures"] = 1 + case "missing_refusal": + delete(tools["read.source"].(map[string]any), "refused_edits") + case "missing_fallback": + delete(tools["read.source"].(map[string]any), "fallback_answers") + case "untested_unsupported": + tools["read.source"].(map[string]any)["refused_edits"] = 0 + } + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + d := dataset{config: inputs{Repeats: 3}} + for repeat := 1; repeat <= 3; repeat++ { + d.runs = append(d.runs, observation{input: runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-4", Repeat: repeat}, data: r}) + } + row := assessEditLoopRow(budget{Metric: "silent_stale_compact", Scenario: "loadedit-4", HostClass: "reference_linux", Target: pointer(0), Compare: "eq", Unit: "count"}, d) + if variant == "complete" { + if row.State != "UNOBSERVABLE" || row.Value != nil || row.Reason == "" { + t.Errorf("qualified compact needs explicit nonnumeric reason: %+v", row) + } + } else if row.State == "PASS" || row.State == "UNOBSERVABLE" || row.Value != nil { + t.Errorf("missing compact proof qualified: %+v", row) + } + }) + } +} + +func TestEditLoopLargeFileRequiresActualTargetCensus(t *testing.T) { + for _, variant := range []string{"complete", "small", "missing"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + for _, value := range raw["edits"].([]any) { + value.(map[string]any)["admitted_target_lines"] = 10000 + } + first := raw["edits"].([]any)[0].(map[string]any) + if variant == "small" { + first["admitted_target_lines"] = 9999 + } + if variant == "missing" { + delete(first, "admitted_target_lines") + } + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + config := inputs{CandidateRevision: strings.Repeat("a", 40), Hosts: map[string]string{"reference_linux": "synthetic-host"}} + err = validateEditLoopReport(config, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-4-largefile"}, r) + if (err == nil) != (variant == "complete") { + t.Errorf("large-file alias accepted without actual target admission, variant%s error%v", variant, err) + } + }) + } +} + +func TestEditLoopSixRepoCellRequiresPinnedRoutedEvidence(t *testing.T) { + for _, variant := range []string{"complete", "missing_routes", "duplicate_route", "foreign_record", "foreign_pin", "one_corpus"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + var raw, workload map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(r.Workload, &workload); err != nil { + t.Fatal(err) + } + workload["edit_agents"] = 6 + plan := raw["plan"].(map[string]any) + plan["editors"] = 6 + routes := make([]any, 6) + r.Corpus = nil + for i := range 6 { + label := fmt.Sprintf("repo-%d", i) + commit := fmt.Sprintf("%040x", i+1) + routes[i] = map[string]any{"id": label, "repo": label, "commit": commit, "file": "fixture.go", "kind": "add_intrafile_call", "tool": "edit.file", "language": "go", "mode": "json"} + r.Corpus = append(r.Corpus, map[string]any{"label": label, "commit": commit}) + } + plan["routes"] = routes + for i, value := range raw["edits"].([]any) { + record := value.(map[string]any) + step := plan["steps"].([]any)[i].(map[string]any) + label := fmt.Sprintf("repo-%d", i%6) + record["editor"], record["route"], step["editor"], step["route"] = i%6, label, i%6, label + } + switch variant { + case "missing_routes": + delete(plan, "routes") + case "duplicate_route": + routes[1].(map[string]any)["id"] = routes[0].(map[string]any)["id"] + case "foreign_record": + raw["edits"].([]any)[0].(map[string]any)["route"] = "repo-1" + case "foreign_pin": + routes[0].(map[string]any)["commit"] = strings.Repeat("f", 40) + case "one_corpus": + r.Corpus = r.Corpus[:1] + } + var err error + r.Workload, err = json.Marshal(workload) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe, err = json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + config := inputs{CandidateRevision: strings.Repeat("a", 40), Hosts: map[string]string{"reference_linux": "synthetic-host"}} + err = validateEditLoopReport(config, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "multirepoedit-6"}, r) + if (err == nil) != (variant == "complete") { + t.Errorf("six-repo alias borrowed unproved routes, variant%s error%v", variant, err) + } + }) + } +} diff --git a/bench/check-budgets/edit_loop_lane.go b/bench/check-budgets/edit_loop_lane.go new file mode 100644 index 000000000..3438d6602 --- /dev/null +++ b/bench/check-budgets/edit_loop_lane.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "path/filepath" + "slices" + "time" +) + +// These are separate repeated smoke runs of one build, each with its own +// <=15m wall limit. The nightly's single-editor Poisson schedule is retained; +// it is not compressed to fit the smoke-only deadline. +func makeEditLoopLanePlan(p policy, c laneConfig) (lanePlan, error) { + plan := lanePlan{Lane: c.Lane, Execution: "edit-loop", HostClass: c.HostClass, Ready: true, PolicySHA256: policyDigest(p), Prerequisites: []string{}} + if c.EditGateSeed != "" && (c.Lane != "smoke" || c.EditGateSeed != "ack-wait" && c.EditGateSeed != "closed-read-gate") { + return plan, errors.New("edit gate faults are closed smoke-only modes") + } + if c.HostClass != "reference_linux" || c.Lane != "smoke" && c.Lane != "nightly" { + return plan, errors.New("edit gates require the reference Linux host and smoke or nightly lane") + } + scenarios := []string{"loadedit-4"} + if c.Lane == "nightly" { + scenarios = []string{"loadedit-1", "loadedit-4", "loadedit-8", "loadedit-16", "loadedit-32", "multirepoedit-6", "loadedit-4-largefile"} + } + if c.Load == "" || c.Manifest == "" || c.Builds["candidate"].Binary == "" { + plan.Prerequisites = append(plan.Prerequisites, "pinned runtime configuration and reviewed owned-copy corpus manifest required") + } + for _, scenario := range scenarios { + for repeat := 1; repeat <= max(3, c.Repeats); repeat++ { + task := laneTask{EditGateSeed: c.EditGateSeed, Scenario: scenario, Role: "candidate", Repeat: repeat, Seed: int64(repeat), EditGate: true, EditCount: 100, ProbeModes: []string{"json", "toon", "compact", "facade", "cli"}} + if scenario == "loadedit-4-largefile" && c.LargeFileManifest == "" { + task.Unavailable = "large-file gate needs actual >=10000-line target admission; a scenario alias is not evidence" + } + plan.Tasks = append(plan.Tasks, task) + } + } + if c.Lane == "nightly" && c.LargeFileManifest == "" { + plan.Prerequisites = append(plan.Prerequisites, "separate large_file_manifest required; actual >=10000-line admission is validated from each report") + } + plan.Ready = len(plan.Prerequisites) == 0 + return plan, nil +} + +func finishEditLoopLaneEvidence(c laneConfig, p policy, plan lanePlan, out string, stdout io.Writer, receipt laneReceipt, index inputs, failures []error) error { + if err := writeNewJSON(filepath.Join(out, "inputs.json"), index); err != nil { + failures = append(failures, err) + } + d, loadErr := loadInputsChecked(filepath.Join(out, "inputs.json"), validateEditLoopReport) + scenarios := map[string]bool{} + for _, task := range plan.Tasks { + scenarios[task.Scenario] = true + } + rows := []editLoopRow{} + pass := true + for _, b := range p.Budgets { + if b.HostClass != c.HostClass || !scenarios[b.Scenario] || !slices.Contains(b.Lane, "edit-smoke") { + continue + } + row := editLoopRow{Metric: b.Metric, Scenario: b.Scenario, State: "UNAVAILABLE", Budget: b.Target, Unit: b.Unit} + if loadErr != nil { + row.Reason = loadErr.Error() + } else { + row = assessEditLoopRow(b, d) + } + if editLoopRowBlocks(row) { + pass = false + } + rows = append(rows, row) + } + if scenarios["multirepoedit-6"] { + row := assessSixRepoReadObservation(d, loadErr) + rows = append(rows, row) + if editLoopRowBlocks(row) { + pass = false + } + } + var table bytes.Buffer + if err := printEditLoopRows(io.MultiWriter(stdout, &table), rows); err != nil { + failures = append(failures, err) + } + if err := writeNewJSON(filepath.Join(out, "budget-results.json"), struct { + Pass bool `json:"pass"` + Rows []editLoopRow `json:"rows"` + }{pass, rows}); err != nil { + failures = append(failures, err) + } + if err := writeNewJSON(filepath.Join(out, "budget-table.json"), table.String()); err != nil { + failures = append(failures, err) + } + receipt.BudgetPass = pass + if !pass || len(rows) == 0 { + failures = append(failures, errors.New("edit-loop budgets have failed or unavailable rows")) + } + if !plan.Ready { + failures = append(failures, errors.New("edit-loop prerequisites unavailable; see plan")) + } + receipt.Finished = time.Now().UTC() + receipt.WallSeconds = receipt.Finished.Sub(receipt.Started).Seconds() + err := errors.Join(failures...) + if err != nil { + receipt.Error = err.Error() + } + return errors.Join(err, writeNewJSON(filepath.Join(out, "lane.json"), receipt), laneSummary("Repeated edit-loop runs", receipt)) +} + +func printEditLoopRows(output io.Writer, rows []editLoopRow) error { + fmt.Fprintln(output, "scenario\tmetric\tvalue\tbudget\tunit\tbaseline\tdelta\tstate\treason") + for _, row := range rows { + if _, err := fmt.Fprintf(output, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", row.Scenario, row.Metric, editLoopNumber(row.Value), editLoopNumber(row.Budget), row.Unit, editLoopNumber(row.Baseline), editLoopNumber(row.Delta), row.State, row.Reason); err != nil { + return err + } + } + return nil +} diff --git a/bench/check-budgets/edit_loop_lane_test.go b/bench/check-budgets/edit_loop_lane_test.go new file mode 100644 index 000000000..ae16a1757 --- /dev/null +++ b/bench/check-budgets/edit_loop_lane_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "bytes" + "encoding/json" + "slices" + "testing" +) + +func TestEditLoopLanePlanKeepsSmokeAndNightlyDenominators(t *testing.T) { + for _, lane := range []string{"smoke", "nightly"} { + t.Run(lane, func(t *testing.T) { + var out, diagnostics bytes.Buffer + exit := run([]string{"edit-plan", "--lane", lane, "--host-class", "reference_linux", "--budgets", "../budgets.yaml"}, &out, &diagnostics) + if exit != 1 { + t.Fatalf("unconfigured planning must retain missing runtime prerequisites, exit%d: %s", exit, diagnostics.String()) + } + var plan struct { + Execution string `json:"execution"` + Ready bool `json:"ready"` + Tasks []struct { + Scenario string `json:"scenario"` + Role string `json:"role"` + Count int `json:"edit_count"` + Modes []string `json:"probe_modes"` + Gate bool `json:"edit_gate"` + } `json:"tasks"` + } + if err := json.Unmarshal(out.Bytes(), &plan); err != nil { + t.Fatalf("no reviewable edit plan: %v: %s", err, out.String()) + } + if plan.Ready || plan.Execution != "edit-loop" { + t.Fatalf("unconfigured execution claims ready or wrong surface: %+v", plan) + } + counts := map[string]int{} + for _, task := range plan.Tasks { + if task.Role != "candidate" || task.Count != 100 || !task.Gate || !slices.Equal(task.Modes, []string{"json", "toon", "compact", "facade", "cli"}) { + t.Errorf("gate task changed actual denominator/surfaces: %+v", task) + } + counts[task.Scenario]++ + } + want := []string{"loadedit-4"} + if lane == "nightly" { + want = []string{"loadedit-1", "loadedit-4", "loadedit-8", "loadedit-16", "loadedit-32", "multirepoedit-6", "loadedit-4-largefile"} + } + if len(counts) != len(want) { + t.Errorf("wrong matrix: %+v", counts) + } + for _, name := range want { + if counts[name] != 3 { + t.Errorf("%s has%d repeats, want3", name, counts[name]) + } + } + }) + } +} + +func TestEditLoopTaskActuallySelectsGateInsteadOfLegacyEditTraffic(t *testing.T) { + for _, scenario := range []string{"loadedit-4", "multirepoedit-6", "loadedit-4-largefile"} { + t.Run(scenario, func(t *testing.T) { + var task laneTask + if err := json.Unmarshal([]byte(`{"scenario":"`+scenario+`","role":"candidate","repeat":1,"edit_gate":true}`), &task); err != nil { + t.Fatal(err) + } + c := fixtureLaneConfig() + c.HostClass = "reference_linux" + args := taskArgs(c, task, "manifest.json", "state", "report.json") + arg := func(name string) string { + for i := range args { + if args[i] == name && i+1 < len(args) { + return args[i+1] + } + } + return "" + } + if arg("--scenario") != "edit-and-probe" || arg("--agents") != "0" || arg("--edit-count") != "100" || !slices.Contains(args, "--edit-gate-modes") { + t.Errorf("actual process still runs legacy edit traffic: %v", args) + } + if scenario == "loadedit-4-largefile" && (arg("--edit-repos") != "1" || arg("--edit-agents") != "4") { + t.Errorf("large-file row has no actual4-editor target: %v", args) + } + if scenario == "multirepoedit-6" && (arg("--edit-repos") != "6" || arg("--edit-agents") != "6") { + t.Errorf("six-repository row lost real routing: %v", args) + } + }) + } +} diff --git a/bench/check-budgets/edit_loop_rows_test.go b/bench/check-budgets/edit_loop_rows_test.go new file mode 100644 index 000000000..6d83ca338 --- /dev/null +++ b/bench/check-budgets/edit_loop_rows_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "os" + "slices" + "testing" +) + +func TestBudgetsDeclareEveryEditLoopRow(t *testing.T) { + data, err := os.ReadFile("../budgets.yaml") + if err != nil { + t.Fatal(err) + } + p, err := loadPolicy(data) + if err != nil { + t.Fatal(err) + } + requireRow := func(scenario, metric string, target float64, unit, compare string) { + t.Helper() + for _, row := range p.Budgets { + if row.Scenario != scenario || row.Metric != metric || row.HostClass != "reference_linux" || !slices.Contains(row.Lane, "edit-smoke") { + continue + } + if row.Mode != "block" || row.OwningRelease != "v0.64.12" || row.Target == nil || *row.Target != target || row.Unit != unit || row.Compare != compare { + t.Errorf("incorrect edit-loop row: %+v", row) + } + return + } + t.Errorf("missing absolute edit-smoke row %s/%s", scenario, metric) + } + for _, cell := range []struct { + scenario string + ack, read float64 + }{ + {"loadedit-1", .15, .25}, {"loadedit-4", .20, .35}, {"loadedit-8", .25, .40}, + {"loadedit-16", .35, .55}, {"loadedit-32", .50, .75}, {"multirepoedit-6", .30, 0}, + } { + requireRow(cell.scenario, "edit_ack_p95", cell.ack, "s", "le") + requireRow(cell.scenario, "edit_ack_max", 1, "s", "le") + if cell.read > 0 { + requireRow(cell.scenario, "readback_p95", cell.read, "s", "le") + } + requireRow(cell.scenario, "readback_max", 1, "s", "le") + requireRow(cell.scenario, "readback_correct", .999, "fraction", "ge") + requireRow(cell.scenario, "reclaim_hold_p95", .05, "s", "le") + requireRow(cell.scenario, "read_gate_closures", 0, "count", "eq") + requireRow(cell.scenario, "wal_peak_mib", 2048, "MiB", "le") + for _, mode := range []string{"json", "toon", "compact", "facade", "cli"} { + requireRow(cell.scenario, "silent_stale_"+mode, 0, "count", "eq") + } + } + + for _, cell := range []struct { + scenario string + walP95 float64 + }{ + {"loadedit-1", 1007}, {"loadedit-4", 1059}, {"loadedit-8", 960}, {"multirepoedit-6", 760}, + } { + requireRow(cell.scenario, "wal_p95_mib", cell.walP95, "MiB", "le") + } + requireRow("loadedit-4-largefile", "edit_ack_p95", 0.6, "s", "le") + requireRow("loadedit-4-largefile", "edit_ack_max", 1, "s", "le") +} + +func TestBudgetTargetsMayDifferOnlyAcrossDisjointLanes(t *testing.T) { + p, err := loadPolicy([]byte(policyFixture)) + if err != nil { + t.Fatal(err) + } + additional := p.Budgets[0] + threshold := 0.1 + additional.Target = &threshold + additional.Lane = []string{"edit-smoke"} + p.Budgets = append(p.Budgets, additional) + if err := p.validate(); err != nil { + t.Fatalf("separate edit-probe lane cannot declare its own absolute target: %v", err) + } + p.Budgets[len(p.Budgets)-1].Lane = []string{"nightly"} + if err := p.validate(); err == nil { + t.Fatal("overlapping lane targets must remain invalid") + } +} diff --git a/bench/check-budgets/edit_loop_seed_test.go b/bench/check-budgets/edit_loop_seed_test.go new file mode 100644 index 000000000..a651c2a21 --- /dev/null +++ b/bench/check-budgets/edit_loop_seed_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func editLoopSeedConfig(t *testing.T, seed, lane string) string { + t.Helper() + config := fixtureLaneConfig() + config.HostClass, config.Lane = "reference_linux", lane + data, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + var wire map[string]any + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + wire["edit_gate_seed"] = seed + data, err = json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestEditLoopCollectorSeedUsesExplicitTaskAndActualProcessArguments(t *testing.T) { + for _, mode := range []string{"ack-wait", "closed-read-gate"} { + t.Run(mode, func(t *testing.T) { + path := editLoopSeedConfig(t, mode, "smoke") + config, err := readLaneConfig(path, true) + if err != nil { + t.Fatalf("explicit seed configuration cannot reach collector: %v", err) + } + plan, err := makeEditLoopLanePlan(policy{}, config) + if err != nil { + t.Fatal(err) + } + if len(plan.Tasks) != 3 { + t.Fatalf("seed lost three consecutive runs: %v", plan.Tasks) + } + for _, task := range plan.Tasks { + data, err := json.Marshal(task) + if err != nil { + t.Fatal(err) + } + var wire map[string]any + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if wire["edit_gate_seed"] != mode { + t.Fatalf("actual task lost fault identity: %s", data) + } + args := taskArgs(config, task, "manifest", "scratch", "report") + index := slices.Index(args, "--edit-gate-seed") + if index < 0 || index+1 >= len(args) || args[index+1] != mode { + t.Fatalf("actual process cannot activate fault: %v", args) + } + } + if _, err := readLaneConfig(path); err == nil { + t.Fatal("ordinary collector accepted edit gate fault") + } + }) + } + for _, cell := range []struct{ seed, lane string }{{"unregistered", "smoke"}, {"ack-wait", "nightly"}} { + if _, err := readLaneConfig(editLoopSeedConfig(t, cell.seed, cell.lane), true); err == nil { + t.Fatal("fault accepted outside the closed smoke-only contract") + } + } +} + +func TestEditLoopSixRepoReadP95RemainsVisibleWithoutInventedBudget(t *testing.T) { + var out, diagnostics bytes.Buffer + code := run([]string{"edit-loop", "--budgets", "../budgets.yaml", "--scenario", "multirepoedit-6", "--inputs", filepath.Join(t.TempDir(), "absent.json")}, &out, &diagnostics) + if code != 1 { + t.Fatalf("missing evidence exit%d, want1", code) + } + found := false + for _, line := range strings.Split(out.String(), "\n") { + if !strings.Contains(line, "readback_p95") { + continue + } + found = true + if !strings.Contains(line, "UNAVAILABLE") || strings.Contains(line, "PASS") { + t.Fatalf("unmeasured six-repo observation claims a verdict: %s", line) + } + } + if !found { + t.Fatal("six-repository edit plus one-read p95 disappeared instead of being explicitly unbudgeted") + } +} + +func TestSixRepoObservationUsesActualRepeatedSamplesWithoutThreshold(t *testing.T) { + d := dataset{config: inputs{Repeats: 3}} + for repeat := 1; repeat <= 3; repeat++ { + d.runs = append(d.runs, observation{input: runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "multirepoedit-6", Repeat: repeat}, data: editLoopEvidenceFixture(t)}) + } + row := assessSixRepoReadObservation(d, nil) + if row.State != "OBSERVATIONAL" || row.Value == nil || *row.Value != .1 || row.Budget != nil || editLoopRowBlocks(row) { + t.Fatalf("observed p95 acquired an invented verdict: %+v", row) + } + row.Metric = "readback_max" + if !editLoopRowBlocks(row) { + t.Fatal("observation qualification weakened universal hard cap") + } + row = assessSixRepoReadObservation(dataset{config: d.config, runs: d.runs[:2]}, nil) + if row.State != "UNAVAILABLE" || row.Value != nil || !editLoopRowBlocks(row) { + t.Fatalf("missing repeat became a numerical observation: %+v", row) + } + d.runs = append(d.runs, d.runs[0]) + if row := assessSixRepoReadObservation(d, nil); row.State != "UNAVAILABLE" || row.Value != nil { + t.Fatalf("duplicate repeat credited twice: %+v", row) + } +} diff --git a/bench/check-budgets/edit_loop_workflow_test.go b/bench/check-budgets/edit_loop_workflow_test.go new file mode 100644 index 000000000..be48a6c9b --- /dev/null +++ b/bench/check-budgets/edit_loop_workflow_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "os" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestEditLoopWorkflowHasRealBlockingReferenceLanesAndRetainedEvidence(t *testing.T) { + data, err := os.ReadFile("../../.github/workflows/edit-loop-gate.yml") + if err != nil { + t.Fatalf("edit gate has no executable workflow: %v", err) + } + var workflow struct { + Permissions map[string]string `yaml:"permissions"` + Concurrency struct { + Cancel bool `yaml:"cancel-in-progress"` + } `yaml:"concurrency"` + Env map[string]string `yaml:"env"` + Jobs map[string]struct { + If string `yaml:"if"` + RunsOn []string `yaml:"runs-on"` + Continue bool `yaml:"continue-on-error"` + Steps []struct { + If string `yaml:"if"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + if workflow.Permissions["contents"] != "read" || len(workflow.Permissions) != 1 || workflow.Concurrency.Cancel || workflow.Env["CGO_ENABLED"] != "1" { + t.Fatal("workflow changed read-only/CGO/owned cleanup contract") + } + for _, lane := range []string{"smoke", "nightly"} { + job, ok := workflow.Jobs[lane] + if !ok || job.Continue || !strings.Contains(job.If, "refs/heads/main") || !strings.Contains(job.If, "ENABLED") || strings.Join(job.RunsOn, ",") != "self-hosted,perf-reference-linux" { + t.Fatalf("%s lane missing explicit trusted reference gate: %+v", lane, job) + } + collects, artifacts := 0, 0 + for _, step := range job.Steps { + if strings.Contains(step.Run, "edit-collect") { + collects++ + for _, flag := range []string{"--expected-candidate", "--candidate-binary", "--load-binary", "--require-lane " + lane} { + if !strings.Contains(step.Run, flag) { + t.Errorf("%s collector lost %s", lane, flag) + } + } + } + if strings.Contains(step.Uses, "actions/upload-artifact@") && step.If == "always()" { + artifacts++ + } + } + if collects != 1 || artifacts != 1 { + t.Fatalf("%s has no actual strict collection/retained failure artifact", lane) + } + } + for _, proof := range []string{"ack-wait", "closed-read-gate", "gortex_edit_gate_seed", "edit_gate_seed", "EDIT_GATE_REFERENCE_SMOKE_CONFIG", "EDIT_GATE_REFERENCE_NIGHTLY_CONFIG"} { + if !strings.Contains(string(data), proof) { + t.Errorf("workflow has no actual %s contract", proof) + } + } +} diff --git a/bench/check-budgets/lane.go b/bench/check-budgets/lane.go index 3a5885b75..09be13ec8 100644 --- a/bench/check-budgets/lane.go +++ b/bench/check-budgets/lane.go @@ -41,31 +41,37 @@ type laneBuild struct { } type laneConfig struct { - Version int `json:"schema_version"` - Lane string `json:"lane"` - HostClass string `json:"host_class"` - Machine string `json:"machine"` - Load string `json:"load_binary"` - Manifest string `json:"manifest"` - MainArtifact string `json:"main_artifact,omitempty"` - Builds map[string]laneBuild `json:"builds"` - Repeats int `json:"repeats"` - Duration string `json:"duration"` - Deadline string `json:"deadline"` - WarmTimeout string `json:"warm_timeout"` - StopTimeout string `json:"stop_timeout"` - SweepDuration string `json:"sweep_duration"` - SweepRepetitions int `json:"sweep_repetitions"` - PRSmokeDrain string `json:"pr_smoke_drain,omitempty"` - started time.Time + EditGateSeed string `json:"edit_gate_seed,omitempty"` + Version int `json:"schema_version"` + Lane string `json:"lane"` + HostClass string `json:"host_class"` + Machine string `json:"machine"` + Load string `json:"load_binary"` + Manifest string `json:"manifest"` + LargeFileManifest string `json:"large_file_manifest,omitempty"` + MainArtifact string `json:"main_artifact,omitempty"` + Builds map[string]laneBuild `json:"builds"` + Repeats int `json:"repeats"` + Duration string `json:"duration"` + Deadline string `json:"deadline"` + WarmTimeout string `json:"warm_timeout"` + StopTimeout string `json:"stop_timeout"` + SweepDuration string `json:"sweep_duration"` + SweepRepetitions int `json:"sweep_repetitions"` + PRSmokeDrain string `json:"pr_smoke_drain,omitempty"` + started time.Time } type laneTask struct { - Scenario string `json:"scenario"` - Role string `json:"role"` - Repeat int `json:"repeat"` - Seed int64 `json:"seed"` - Unavailable string `json:"unavailable,omitempty"` + EditGateSeed string `json:"edit_gate_seed,omitempty"` + EditGate bool `json:"edit_gate,omitempty"` + EditCount int `json:"edit_count,omitempty"` + ProbeModes []string `json:"probe_modes,omitempty"` + Scenario string `json:"scenario"` + Role string `json:"role"` + Repeat int `json:"repeat"` + Seed int64 `json:"seed"` + Unavailable string `json:"unavailable,omitempty"` } type lanePlan struct { @@ -99,7 +105,7 @@ type laneReceipt struct { Smoke *smokeProvenance `json:"smoke_provenance,omitempty"` } -func readLaneConfig(path string) (laneConfig, error) { +func readLaneConfig(path string, editGate ...bool) (laneConfig, error) { var c laneConfig data, err := os.ReadFile(path) if err != nil { @@ -113,6 +119,9 @@ func readLaneConfig(path string) (laneConfig, error) { if d.Decode(new(any)) != io.EOF { return c, errors.New("lane config must contain exactly one JSON document") } + if c.EditGateSeed != "" && (len(editGate) == 0 || !editGate[0] || c.Lane != "smoke" || c.EditGateSeed != "ack-wait" && c.EditGateSeed != "closed-read-gate") { + return c, errors.New("edit_gate_seed requires the explicit edit smoke collector and ack-wait or closed-read-gate") + } if c.Version != 1 || !slices.Contains([]string{"smoke", "nightly", "release"}, c.Lane) || c.Repeats < 3 || c.Repeats > 100 || c.Machine == "" && c.HostClass != hostedSmokeClass { return c, errors.New("lane config needs schema1, smoke/nightly/release, 3–100 repeats and reviewed machine binding") } @@ -159,10 +168,13 @@ func readLaneConfig(path string) (laneConfig, error) { return filepath.Join(base, value) } c.Load, c.Manifest = resolve(c.Load), resolve(c.Manifest) + if c.LargeFileManifest != "" { + c.LargeFileManifest = resolve(c.LargeFileManifest) + } if c.MainArtifact != "" { c.MainArtifact = resolve(c.MainArtifact) } - if c.Lane == "smoke" && c.MainArtifact == "" { + if c.Lane == "smoke" && c.MainArtifact == "" && (len(editGate) == 0 || !editGate[0]) { return c, errors.New("smoke requires the verified exact-main artifact bundle") } for role, build := range c.Builds { @@ -240,6 +252,8 @@ func policyDigest(p policy) string { } func laneCommand(command string, args []string, stdout, stderr io.Writer) int { + editGate := strings.HasPrefix(command, "edit-") + command = strings.TrimPrefix(command, "edit-") f := flag.NewFlagSet(command, flag.ContinueOnError) f.SetOutput(stderr) config := f.String("config", "", "strict pinned lane config") @@ -270,7 +284,7 @@ func laneCommand(command string, args []string, stdout, stderr io.Writer) int { c := laneConfig{Lane: *lane, HostClass: *host, Repeats: 3} var err error if *config != "" { - c, err = readLaneConfig(*config) + c, err = readLaneConfig(*config, editGate) } else if !slices.Contains([]string{"smoke", "nightly", "release"}, *lane) { err = errors.New("unknown lane") } @@ -328,7 +342,12 @@ func laneCommand(command string, args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, err) return 1 } - plan, err := makeLanePlan(p, c) + var plan lanePlan + if editGate { + plan, err = makeEditLoopLanePlan(p, c) + } else { + plan, err = makeLanePlan(p, c) + } if err != nil { fmt.Fprintln(stderr, err) return 1 @@ -391,7 +410,11 @@ func writeNewJSON(path string, value any) error { } func taskManifest(c laneConfig, task laneTask, root string) (string, error) { - data, err := os.ReadFile(c.Manifest) + manifest := c.Manifest + if task.EditGate && task.Scenario == "loadedit-4-largefile" { + manifest = c.LargeFileManifest + } + data, err := os.ReadFile(manifest) if err != nil { return "", err } @@ -400,6 +423,9 @@ func taskManifest(c laneConfig, task laneTask, root string) (string, error) { return "", err } spec := scenarioSpecs[task.Scenario] + if task.EditGate && task.Scenario == "loadedit-4-largefile" { + spec.editors, spec.repos = 4, 1 + } if len(corpora) < spec.repos { return "", fmt.Errorf("%s needs %d pinned corpora", task.Scenario, spec.repos) } @@ -429,12 +455,24 @@ func taskManifest(c laneConfig, task laneTask, root string) (string, error) { func taskArgs(c laneConfig, task laneTask, manifest, scratch, report string) []string { spec := scenarioSpecs[task.Scenario] + if task.EditGate { + spec.wire, spec.agents = "edit-and-probe", 0 + if task.Scenario == "loadedit-4-largefile" { + spec.editors, spec.repos = 4, 1 + } + } host := "native" if c.HostClass == "reference_linux" { host = "reference" } build := c.Builds[task.Role] args := []string{"run", "--binary", build.Binary, "--git-sha", build.Revision, "--manifest", manifest, "--scratch", scratch, "--out", report, "--host", host, "--profile", "agent", "--scenario", spec.wire, "--agents", fmt.Sprint(spec.agents), "--edit-agents", fmt.Sprint(spec.editors), "--worktrees", fmt.Sprint(spec.worktrees), "--seed", fmt.Sprint(task.Seed), "--duration", c.Duration, "--deadline", c.Deadline, "--warm-timeout", c.WarmTimeout, "--stop-timeout", c.StopTimeout, "--sweep-repetitions", fmt.Sprint(c.SweepRepetitions), "--sweep-duration", c.SweepDuration} + if task.EditGate { + args = append(args, "--edit-gate-modes", "--edit-count", "100", "--edit-repos", fmt.Sprint(spec.repos)) + } + if task.EditGate && task.EditGateSeed != "" { + args = append(args, "--edit-gate-seed", task.EditGateSeed) + } if c.Lane == "smoke" && spec.wire == "read" && c.PRSmokeDrain != "" { args = append(args, "--pr-smoke-drain", c.PRSmokeDrain) } @@ -463,7 +501,7 @@ func collectLane(ctx context.Context, c laneConfig, p policy, plan lanePlan, out if server, repo, id := os.Getenv("GITHUB_SERVER_URL"), os.Getenv("GITHUB_REPOSITORY"), os.Getenv("GITHUB_RUN_ID"); server != "" && repo != "" && id != "" { receipt.Workflow.RunURL = server + "/" + repo + "/actions/runs/" + id } - if c.Lane == "smoke" { + if c.Lane == "smoke" && plan.Execution != "edit-loop" { proof, err := prepareSmokeProvenance(c, receipt.Workflow) if err != nil { receipt.Error = err.Error() @@ -510,7 +548,18 @@ func collectLane(ctx context.Context, c laneConfig, p policy, plan lanePlan, out var log *os.File log, err = os.OpenFile(filepath.Join(root, "client.log"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err == nil { - err = runProcess(ctx, c.Load, taskArgs(c, task, manifest, filepath.Join(root, "state"), reportPath), log) + taskCtx := ctx + cancel := func() {} + if task.EditGate && c.Lane == "smoke" { + // Interrupt the signal-aware client at 13m, preserving its + // independent <=2m owned cleanup inside the 15m per-run cap. + taskCtx, cancel = context.WithDeadline(ctx, tr.Started.Add(13*time.Minute)) + } + err = runProcess(taskCtx, c.Load, taskArgs(c, task, manifest, filepath.Join(root, "state"), reportPath), log) + cancel() + if task.EditGate && c.Lane == "smoke" && time.Since(tr.Started) > 15*time.Minute { + err = errors.Join(err, errors.New("edit smoke run exceeded 15 minutes including startup and cleanup")) + } err = errors.Join(err, log.Close()) } } @@ -523,6 +572,9 @@ func collectLane(ctx context.Context, c laneConfig, p policy, plan lanePlan, out tr.Finished = time.Now().UTC() receipt.Tasks = append(receipt.Tasks, tr) } + if plan.Execution == "edit-loop" { + return finishEditLoopLaneEvidence(c, p, plan, out, stdout, receipt, index, failures) + } return finishLaneEvidence(c, p, plan, out, stdout, receipt, index, failures) } diff --git a/bench/check-budgets/main.go b/bench/check-budgets/main.go index 035e329f8..2238a65c6 100644 --- a/bench/check-budgets/main.go +++ b/bench/check-budgets/main.go @@ -19,7 +19,9 @@ func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } func run(args []string, stdout, stderr io.Writer) int { if len(args) > 0 { switch args[0] { - case "plan", "collect": + case "edit-loop": + return editLoopCommand(args[1:], stdout, stderr) + case "plan", "collect", "edit-plan", "edit-collect": return laneCommand(args[0], args[1:], stdout, stderr) case "history": return historyCommand(args[1:], stdout, stderr) diff --git a/bench/check-budgets/policy.go b/bench/check-budgets/policy.go index 1a0ba68f3..0d0b7f1dd 100644 --- a/bench/check-budgets/policy.go +++ b/bench/check-budgets/policy.go @@ -151,10 +151,13 @@ func (p policy) validate() error { return fmt.Errorf("budget %d: %w", i+1, err) } key := b.HostClass + "/" + b.Scenario + "/" + b.Metric - if seen[key] { - return fmt.Errorf("duplicate budget %s", key) + for _, lane := range b.Lane { + laneKey := key + "/" + lane + if seen[laneKey] { + return fmt.Errorf("duplicate budget %s", laneKey) + } + seen[laneKey] = true } - seen[key] = true } return nil } @@ -181,7 +184,7 @@ func validateBudget(b budget, allowances map[string]allowance) error { return errors.New("min_repeats cannot weaken the three-repeat minimum") } for i, lane := range b.Lane { - if !slices.Contains([]string{"smoke", "nightly", "release"}, lane) || slices.Contains(b.Lane[:i], lane) { + if !slices.Contains([]string{"smoke", "nightly", "release", "edit-smoke"}, lane) || slices.Contains(b.Lane[:i], lane) { return fmt.Errorf("unknown or repeated lane %s", lane) } } diff --git a/bench/check-budgets/reports.go b/bench/check-budgets/reports.go index ea8b2833b..c3c8658c8 100644 --- a/bench/check-budgets/reports.go +++ b/bench/check-budgets/reports.go @@ -61,6 +61,7 @@ type attempt struct { } type phase struct { + EditProbe json.RawMessage `json:"edit_probe,omitempty"` SessionModel string `json:"session_model"` Name string `json:"name"` Duration float64 `json:"duration_seconds"` @@ -110,7 +111,9 @@ type dataset struct { runs []observation } -func loadInputs(path string) (dataset, error) { +func loadInputs(path string) (dataset, error) { return loadInputsChecked(path, validateReport) } + +func loadInputsChecked(path string, validate func(inputs, runInput, report) error) (dataset, error) { var out dataset data, err := os.ReadFile(path) if err != nil { @@ -166,7 +169,7 @@ func loadInputs(path string) (dataset, error) { if err != nil { return out, fmt.Errorf("%s: %w", cell, err) } - if err := validateReport(c, input, r); err != nil { + if err := validate(c, input, r); err != nil { return out, fmt.Errorf("%s: %w", cell, err) } if ids[r.RunID] { diff --git a/bench/check-budgets/specs.go b/bench/check-budgets/specs.go index a2dcfbf5d..db70cd5d0 100644 --- a/bench/check-budgets/specs.go +++ b/bench/check-budgets/specs.go @@ -11,6 +11,8 @@ type metricSpec struct { } var metricSpecs = map[string]metricSpec{ + "readback_max": {unit: "s", axis: "timing", everyRepeat: true, missing: "report-v1 does not retain exact edited-file readback latency"}, + "wal_p95_mib": {unit: "MiB", axis: "disk", missing: "report-v1 does not retain a WAL observation distribution"}, "edit_ack_p95": {unit: "s", axis: "timing"}, "edit_ack_max": {unit: "s", axis: "timing", everyRepeat: true}, "read_p95": {unit: "s", axis: "timing"}, @@ -49,6 +51,11 @@ var metricSpecs = map[string]metricSpec{ "idle_written_mib_min": {unit: "MiB/min", axis: "disk", missing: "report-v1 lacks an idle-write observation interval"}, "idle_memory_60s_mib": {unit: "MiB", axis: "memory", missing: "idle_after_2s is not a 60-second post-enrichment footprint observation"}, "cold_go_sys_peak_mib": {unit: "MiB", axis: "memory", missing: "daemon status Go Sys snapshots do not establish cold-window peak Go Sys"}, + "silent_stale_json": {unit: "count", axis: "safety", everyRepeat: true, missing: "report-v1 does not retain mode-specific edit oracle observations"}, + "silent_stale_toon": {unit: "count", axis: "safety", everyRepeat: true, missing: "report-v1 does not retain mode-specific edit oracle observations"}, + "silent_stale_compact": {unit: "count", axis: "safety", everyRepeat: true, missing: "report-v1 does not retain mode-specific edit oracle observations"}, + "silent_stale_facade": {unit: "count", axis: "safety", everyRepeat: true, missing: "report-v1 does not retain mode-specific edit oracle observations"}, + "silent_stale_cli": {unit: "count", axis: "safety", everyRepeat: true, missing: "report-v1 does not retain mode-specific edit oracle observations"}, } type scenarioSpec struct { @@ -71,6 +78,7 @@ func buildScenarioSpecs() map[string]scenarioSpec { out["load-"+cell.name] = scenarioSpec{wire: "read", agents: cell.n, repos: 1} out["loadedit-"+cell.name] = scenarioSpec{wire: "edit", editors: cell.n, repos: 1} } + out["loadedit-4-largefile"] = scenarioSpec{missing: "frozen report-v1 has no identified large-file edit-probe fixture"} out["multirepoedit-6"] = scenarioSpec{wire: "many-edit", editors: 6, repos: 6} for _, cell := range []struct { name string diff --git a/bench/load/discovery_seed.go b/bench/load/discovery_seed.go index a61134021..903c0b89d 100644 --- a/bench/load/discovery_seed.go +++ b/bench/load/discovery_seed.go @@ -126,6 +126,7 @@ func prepareDiscoveryState(ctx context.Context, o runOptions, corpora []Corpus) if err != nil { return err } + p.editACKMode = o.EditACKMode defer os.RemoveAll(p.SocketDir) complete := false defer func() { diff --git a/bench/load/edit_ack_mode_test.go b/bench/load/edit_ack_mode_test.go new file mode 100644 index 000000000..192fe1286 --- /dev/null +++ b/bench/load/edit_ack_mode_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func editACKModeArgs() []string { + return []string{"--binary", "binary", "--manifest", "manifest", "--scratch", "scratch", "--scenario", "read", "--agents", "1", "--edit-agents", "0", "--worktrees", "0"} +} + +func TestExplicitEditACKModeValidatesOnlyTheTwoConfigurationPolicies(t *testing.T) { + if _, err := parseRun(editACKModeArgs()); err != nil { + t.Fatal(err) + } + for _, mode := range []string{"local", "full", "unknown", "LOCAL"} { + t.Run(mode, func(t *testing.T) { + options, err := parseRun(append(editACKModeArgs(), "--edit-ack", mode)) + if mode != "local" && mode != "full" { + if err == nil { + t.Fatal("invalid startup ACK policy accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(options) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + if fields["EditACKMode"] != mode { + t.Fatalf("selected startup policy lost: %s", data) + } + }) + } +} + +func TestExplicitEditACKModeSeparatesWorkloadIdentity(t *testing.T) { + options, err := parseRun(editACKModeArgs()) + if err != nil { + t.Fatal(err) + } + hashes := map[string]string{} + for _, mode := range []string{"", "local", "full"} { + selected := options + // Decode by field name so this regression executes before the new + // option exists; an ignored option cannot manufacture plan identity. + data, err := json.Marshal(map[string]string{"EditACKMode": mode}) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &selected); err != nil { + t.Fatal(err) + } + report, err := newRunReport(selected, []Corpus{sampleCorpus()}, strings.Repeat("a", 40), Host{}, "owned-ack-mode") + if err != nil { + t.Fatal(err) + } + fields := copyJSON(report.Workload) + if mode == "" { + if _, exists := fields["edit_ack_mode"]; exists { + t.Fatal("ordinary workload acquired explicit ACK policy metadata") + } + } else if fields["edit_ack_mode"] != mode { + t.Errorf("requested startup mode missing from normalized workload: mode=%q fields=%v", mode, fields) + } + hashes[mode] = report.RequestPlan + } + if hashes["local"] == hashes["full"] { + t.Fatal("local and full ACK configurations have identical workload identity") + } +} + +// Crash the owned fixture before readiness. This proves startup configuration +// only, never effective ACK semantics or any measured edit workload. +func TestExplicitEditACKModeReachesOwnedBootstrapConfig(t *testing.T) { + t.Setenv("LOAD_DAEMON_FIXTURE", "crash") + corpus := gitFixture(t) + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, mode := range []string{"", "local", "full"} { + name := mode + if name == "" { + name = "ordinary" + } + t.Run(name, func(t *testing.T) { + options := runOptions{Binary: binary, Profile: "agent", Host: "native", Scenario: "read", Agents: 1, Worktrees: 0, Seed: 42, Duration: time.Millisecond, Deadline: time.Second, WarmTimeout: 2 * time.Second, StopTimeout: time.Second, KeepState: true} + data, err := json.Marshal(map[string]string{"EditACKMode": mode}) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &options); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + _, _, artifacts, _, _, phaseErr := runPhase(ctx, options, "read", []Corpus{corpus}, "", t.TempDir()) + if phaseErr == nil { + t.Fatal("startup control unexpectedly ran a workload") + } + root := artifacts["private_root"] + if root == "" { + t.Fatalf("startup did not reach owned configuration: %v", phaseErr) + } + data, err = os.ReadFile(filepath.Join(root, "config", "gortex", "config.yaml")) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + if mode == "" { + if _, exists := config["edit"]; exists { + t.Fatal("ordinary configuration changed default bytes") + } + } else if edit, ok := config["edit"].(map[string]any); !ok || edit["ack"] != mode { + t.Fatalf("owned daemon did not receive selected edit.ack=%s: %s", mode, data) + } + }) + } +} diff --git a/bench/load/edit_gate_facade.go b/bench/load/edit_gate_facade.go new file mode 100644 index 000000000..595f76a61 --- /dev/null +++ b/bench/load/edit_gate_facade.go @@ -0,0 +1,47 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" +) + +type editProbeFacadeWire struct{ operation string } + +const editProbeFacadeWireKey = "\x00load.facade.wire" + +// Surface identity is proof from the actual MCP envelope, independent from +// its JSON payload. A GCX/TOON decoder or structured sibling cannot supply it. +func verifyEditProbeFacade(raw json.RawMessage, operation string) error { + canonical := editProbeCanonicalTool(operation) + + facade, op, ok := strings.Cut(operation, ".") + if !ok || canonical == "" { + return errors.New("facade dispatch proof unavailable for this operation") + } + var wire struct { + Meta struct { + Identity struct { + Surface string `json:"surface_version"` + Facade string `json:"facade"` + Operation string `json:"operation"` + Canonical string `json:"canonical_tool"` + } `json:"gortex_facade"` + } `json:"_meta"` + } + if err := json.Unmarshal(raw, &wire); err != nil { + return err + } + identity := wire.Meta.Identity + if identity.Surface != "facade-v1" || identity.Facade != facade || identity.Operation != op || identity.Canonical != canonical { + return errors.New("missing or mismatched facade-v1 dispatch identity") + } + return nil +} + +func editProbeCanonicalTool(operation string) string { + return map[string]string{ + "read.file": "read_file", "read.source": "get_symbol_source", "read.summary": "get_file_summary", "read.editing_context": "get_editing_context", + "search.symbols": "search_symbols", "search.text": "search_text", "relations.callers": "get_callers", "trace.call_chain": "get_call_chain", + }[operation] +} diff --git a/bench/load/edit_gate_fresh_prefix_test.go b/bench/load/edit_gate_fresh_prefix_test.go new file mode 100644 index 000000000..3b7d9ae00 --- /dev/null +++ b/bench/load/edit_gate_fresh_prefix_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "encoding/json" + "errors" + "testing" + + toon "github.com/toon-format/toon-go" +) + +func TestEditGateDecodesExplicitFreshScopeWithoutInventingMissingClaims(t *testing.T) { + encoded, err := toon.Marshal(map[string]any{"nodes": []any{map[string]any{"id": "fixture/main.go::Added"}}, "total": 1, "truncated": false}) + if err != nil { + t.Fatal(err) + } + for _, scope := range []string{"file", "relations", "derived"} { + body, err := decodeEditProbeOutput(outputWire(scope+": fresh (0 files)\n"+string(encoded), nil), "toon", "read.summary") + if err != nil { + t.Fatalf("actual explicit fresh prefix cannot be decoded: %v", err) + } + if body["graph_freshness"] != "fresh" || body["freshness_scope"] != scope || body["pending_file_count"] != json.Number("0") || !editProbeClaimsFresh(body) { + t.Fatalf("actual scoped claim discarded: %+v", body) + } + } + for _, text := range []string{"relations: fresh (2 files)\n" + string(encoded), "relations: fresh (0 files)\nfile: fresh (0 files)\n" + string(encoded)} { + if _, err := decodeEditProbeOutput(outputWire(text, nil), "toon", "read.summary"); err == nil { + t.Fatal("contradictory/repeated fresh prefix accepted") + } + } + body, err := decodeEditProbeOutput(outputWire(string(encoded), map[string]any{"graph_freshness": "fresh"}), "toon", "read.summary") + if err != nil { + t.Fatal(err) + } + if editProbeClaimsFresh(body) { + t.Fatal("missing actual fresh claim was borrowed from structured sibling") + } + gcx := "GCX1 tool=get_file_summary fields=id graph_freshness=fresh freshness_scope=relations pending_file_count=0\n# relations: fresh (0 files)\nfixture/main.go::Added\n" + body, err = decodeEditProbeOutput(outputWire(gcx, nil), "gcx", "read.summary") + if !errors.Is(err, errEditOutputGCXUnavailable) || body["graph_freshness"] != "fresh" { + t.Fatalf("GCX header lost actual metadata or acquired row decoding: %v %v", body, err) + } +} diff --git a/bench/load/edit_gate_fresh_request_test.go b/bench/load/edit_gate_fresh_request_test.go new file mode 100644 index 000000000..ea395dcb7 --- /dev/null +++ b/bench/load/edit_gate_fresh_request_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestEditGateRequestsAnswerFreshnessWithoutChangingAutoViewOrOrdinaryReads(t *testing.T) { + for _, cell := range []struct { + mode string + gate, want bool + }{{"json", true, true}, {"toon", true, true}, {"facade", true, true}, {"compact", true, false}, {"json", false, false}} { + t.Run(cell.mode+map[bool]string{true: "-gate", false: "-ordinary"}[cell.gate], func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + plan.GateModes = cell.gate + plan.Steps[0].ProbeMode = cell.mode + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\n"+c.EditAnchor+"\n"), 0600); err != nil { + t.Fatal(err) + } + observed := make(chan map[string]any, 64) + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(frame stubFrame) (any, *rpcError) { + if frame.Method == "tools/call" { + args, _ := frame.Params["arguments"].(map[string]any) + row := map[string]any{"tool": frame.Params["name"], "operation": args["operation"], "require_fresh": args["require_fresh"], "view": args["view"]} + select { + case observed <- row: + default: + } + } + return stubContent(map[string]any{}), nil + }) + state := run.states[0] + defer state.finishRaw() + run.probe(ctx, cancel, state) + seen := map[string]bool{} + for len(seen) < 7 { + select { + case row := <-observed: + tool, _ := row["tool"].(string) + operation, _ := row["operation"].(string) + seen[tool+"."+operation] = true + if (row["require_fresh"] == true) != cell.want { + t.Errorf("actual %s.%s freshness request=%v, want%t", tool, operation, row["require_fresh"], cell.want) + } + if row["view"] != nil { + t.Errorf("gate silently changed AUTO scope to%v", row["view"]) + } + case <-ctx.Done(): + t.Fatalf("did not observe all seven actual graph probe operations: %v", seen) + } + } + cancel() + state.probes.Wait() + }) + } +} diff --git a/bench/load/edit_gate_freshness.go b/bench/load/edit_gate_freshness.go new file mode 100644 index 000000000..2dc88e9e6 --- /dev/null +++ b/bench/load/edit_gate_freshness.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + wire "github.com/gortexhq/gcx-go" +) + +var editProbeTierLine = regexp.MustCompile(`^(file|relations|derived): (pending|pending_unknown|unknown|failed|fresh) \(([0-9]+) files\)$`) + +// The label is producer evidence, not disposable prose. Keep its exact scope, +// state and count while decoding the underlying compact/TOON payload normally. +func editProbeTierPrefix(text []byte) ([]byte, map[string]any, error) { + first, rest, found := bytes.Cut(text, []byte("\n")) + match := editProbeTierLine.FindSubmatch(first) + if match == nil { + return text, nil, nil + } + if !found { + return nil, nil, errors.New("freshness label has no payload boundary") + } + count, err := strconv.ParseUint(string(match[3]), 10, 63) + if err != nil { + return nil, nil, err + } + if string(match[2]) == "fresh" && count != 0 { + return nil, nil, errors.New("freshness label claims fresh with pending files") + } + next, _, _ := bytes.Cut(rest, []byte("\n")) + if editProbeTierLine.Match(next) { + return nil, nil, errors.New("repeated freshness label") + } + return rest, map[string]any{"graph_freshness": string(match[2]), "freshness_scope": string(match[1]), "pending_file_count": json.Number(strconv.FormatUint(count, 10))}, nil +} + +func editProbeKeepTierFields(body, fields map[string]any) error { + for key, value := range fields { + if old, exists := body[key]; exists && fmt.Sprint(old) != fmt.Sprint(value) { + return errors.New("payload contradicts its freshness label") + } + body[key] = value + } + return nil +} + +// Only the existing GCX header decoder is used. Rows stay unsupported and can +// never supply payload/format credit from a TOON fallback or metadata alone. +func editProbeGCXTierFields(raw json.RawMessage, operation string) (map[string]any, error) { + text, err := editOutputPayload(raw, false) + if err != nil { + return nil, err + } + header, err := wire.NewDecoder(bytes.NewReader(text)).Header() + if err != nil { + return nil, err + } + if canonical := editProbeCanonicalTool(operation); canonical == "" || header.Tool != canonical { + return nil, errors.New("GCX header belongs to another operation") + } + state, scope, count := header.Meta["graph_freshness"], header.Meta["freshness_scope"], header.Meta["pending_file_count"] + if state == "" && scope == "" && count == "" { + return nil, nil + } + label := scope + ": " + state + " (" + count + " files)" + _, fields, err := editProbeTierPrefix([]byte(label + "\n")) + if err != nil || fields == nil { + return nil, errors.New("incomplete or invalid GCX freshness metadata") + } + _, rows, _ := bytes.Cut(text, []byte("\n")) + comment, _, _ := bytes.Cut(rows, []byte("\n")) + if bytes.HasPrefix(comment, []byte("# ")) { + observed := strings.TrimPrefix(string(comment), "# ") + if editProbeTierLine.MatchString(observed) && observed != label { + return nil, errors.New("GCX header and comment freshness disagree") + } + } + return fields, nil +} + +// Live daemon-clock observations remain separate from client and monotonic +// receipt windows. Missing stamps stay missing; equal clock cells are valid. +type editProbeLiveTiers struct { + State string `json:"observation_state"` + Reason string `json:"reason,omitempty"` + ACK string `json:"ack_at,omitempty"` + FileGraph string `json:"file_graph_at,omitempty"` + Relations string `json:"relations_at,omitempty"` + Derived string `json:"derived_at,omitempty"` + Tiers map[string]string `json:"tier_state,omitempty"` +} + +func editProbeStatusTiers(body map[string]any) *editProbeLiveTiers { + out := &editProbeLiveTiers{State: "observed"} + previous := time.Time{} + seen := false + for _, field := range []struct { + key string + target *string + }{{"ack_at", &out.ACK}, {"file_graph_at", &out.FileGraph}, {"relations_at", &out.Relations}, {"derived_at", &out.Derived}} { + raw, exists := body[field.key] + if !exists { + continue + } + seen = true + value, ok := raw.(string) + at, err := time.Parse(time.RFC3339Nano, value) + if !ok || err != nil || at.IsZero() || !previous.IsZero() && at.Before(previous) { + out.State, out.Reason = "invalid", "missing, malformed or reversed live tier stamp" + return out + } + *field.target = value + previous = at + } + if raw, exists := body["tier_state"]; exists { + seen = true + states, ok := raw.(map[string]any) + if !ok { + out.State, out.Reason = "invalid", "tier_state is not an object" + return out + } + out.Tiers = map[string]string{} + for _, tier := range []string{"file_graph", "relations", "derived"} { + value, ok := states[tier].(string) + if !ok || value != "fresh" && value != "pending" && value != "failed" && value != "partial" { + out.State, out.Reason = "invalid", "unknown live tier state" + return out + } + stamp := map[string]string{"file_graph": out.FileGraph, "relations": out.Relations, "derived": out.Derived}[tier] + if value == "fresh" && stamp == "" { + out.State, out.Reason = "invalid", "fresh live tier has no timestamp" + return out + } + out.Tiers[tier] = value + } + } + if !seen { + return nil + } + return out +} diff --git a/bench/load/edit_gate_modes_test.go b/bench/load/edit_gate_modes_test.go new file mode 100644 index 000000000..97af39d6a --- /dev/null +++ b/bench/load/edit_gate_modes_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestEditGateModesKeepFourEditorScheduleAndJSONACK(t *testing.T) { + args := []string{"--binary", "unused", "--manifest", "unused", "--scratch", t.TempDir(), "--scenario", editProbeScenario, "--agents", "0", "--edit-agents", "4", "--worktrees", "0"} + ordinary, err := parseRun(args) + if err != nil { + t.Fatal(err) + } + c := editProbeCorpus() + baseline, err := planEditProbe(c, ordinary) + if err != nil { + t.Fatal(err) + } + options, err := parseRun(append(args, "--edit-gate-modes")) + if err != nil { + t.Fatalf("gate-specific five-mode coverage cannot be planned: %v", err) + } + plan, err := planEditProbe(c, options) + if err != nil { + t.Fatal(err) + } + if plan.Count != 100 || plan.Editors != 4 || plan.Mode != "json" || len(plan.Steps) != 100 { + t.Fatalf("gate changed the ACK workload: %+v", plan) + } + data, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + var wire struct { + Steps []struct { + Mode string `json:"probe_mode"` + } `json:"steps"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + modes := []string{"json", "toon", "compact", "facade", "cli"} + counts := map[string]int{} + for i, step := range plan.Steps { + if got := wire.Steps[i].Mode; got != modes[i%len(modes)] { + t.Fatalf("ordinal%d planned mode%q, want%q", i, got, modes[i%len(modes)]) + } + counts[wire.Steps[i].Mode]++ + old := baseline.Steps[i] + if step.DueNS != old.DueNS || step.ActivateNS != old.ActivateNS || step.Editor != old.Editor || step.Marker != old.Marker || step.MutationID != old.MutationID || step.RequestHash != old.RequestHash || !reflect.DeepEqual(step.Request, old.Request) { + t.Fatalf("mode coverage changed offered edit/ACK at ordinal%d", i) + } + } + for _, mode := range modes { + if counts[mode] != 20 { + t.Fatalf("planned denominator %s=%d, want20 of100 total", mode, counts[mode]) + } + } + recordData, err := json.Marshal(newEditProbeReport(plan, c, true)) + if err != nil { + t.Fatal(err) + } + var report struct { + Records []struct { + Mode string `json:"probe_mode"` + } `json:"edits"` + } + if err := json.Unmarshal(recordData, &report); err != nil { + t.Fatal(err) + } + if len(report.Records) != 100 { + t.Fatal("censored setup lost offered edits") + } + for i, record := range report.Records { + if record.Mode != modes[i%len(modes)] { + t.Fatalf("censored ordinal%d lost explicit mode", i) + } + } +} + +func TestOrdinaryEditPlanHasNoGateModeAssignments(t *testing.T) { + c := editProbeCorpus() + old, err := makeEditProbeTrafficPlan(c, 42, 100, 4) + if err != nil { + t.Fatal(err) + } + got, err := planEditProbe(c, runOptions{Seed: 42, EditCount: 100, Editors: 4}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(old, got) { + t.Fatal("ordinary plan changed without gate opt-in") + } + data, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + var wire map[string]json.RawMessage + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if _, exists := wire["gate_modes"]; exists { + t.Fatal("default hash gained gate metadata") + } +} + +func TestEditGateFacadeRequiresMatchingDispatchEnvelopeAndActualJSON(t *testing.T) { + makeWire := func(change string) json.RawMessage { + identity := map[string]any{"surface_version": "facade-v1", "facade": "read", "operation": "source", "canonical_tool": "get_symbol_source"} + wire := map[string]any{"content": []any{map[string]any{"type": "text", "text": "{\"source\":\"marker\"}"}}, "_meta": map[string]any{"gortex_facade": identity}} + switch change { + case "missing": + delete(wire, "_meta") + case "wrong_surface": + identity["surface_version"] = "legacy" + case "wrong_facade": + identity["facade"] = "search" + case "wrong_operation": + identity["operation"] = "summary" + case "wrong_canonical": + identity["canonical_tool"] = "read_file" + case "structured_only": + wire["content"] = []any{} + wire["structuredContent"] = map[string]any{"source": "marker"} + case "non_json": + wire["content"] = []any{map[string]any{"type": "text", "text": "source: marker"}} + wire["structuredContent"] = map[string]any{"source": "marker"} + } + data, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + return data + } + body, err := decodeEditProbeOutput(makeWire(""), "facade", "read.source") + if err != nil { + t.Fatalf("actual facade-v1 result cannot be observed: %v", err) + } + if body["source"] != "marker" { + t.Fatal("facade proof replaced actual payload semantics") + } + for _, change := range []string{"missing", "wrong_surface", "wrong_facade", "wrong_operation", "wrong_canonical", "structured_only", "non_json"} { + t.Run(change, func(t *testing.T) { + if _, err := decodeEditProbeOutput(makeWire(change), "facade", "read.source"); err == nil { + t.Fatal("dispatch or payload evidence was fabricated") + } + }) + } +} + +func TestEditGateJSONAndFacadeRefuseNullPayloadWithoutPanic(t *testing.T) { + for _, mode := range []string{"json", "facade"} { + t.Run(mode, func(t *testing.T) { + defer func() { + if value := recover(); value != nil { + t.Errorf("invalid actual response panicked instead of refusing evidence: %v", value) + } + }() + raw := outputWire("null", nil) + if mode == "facade" { + var envelope map[string]any + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatal(err) + } + envelope["_meta"] = map[string]any{"gortex_facade": map[string]any{"surface_version": "facade-v1", "facade": "read", "operation": "summary", "canonical_tool": "get_file_summary"}} + var err error + raw, err = json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + } + if _, err := decodeEditProbeOutput(raw, mode, "read.summary"); err == nil { + t.Error("null payload counted as a decoded answer") + } + }) + } +} diff --git a/bench/load/edit_gate_runtime_test.go b/bench/load/edit_gate_runtime_test.go new file mode 100644 index 000000000..5840088bb --- /dev/null +++ b/bench/load/edit_gate_runtime_test.go @@ -0,0 +1,430 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + toon "github.com/toon-format/toon-go" +) + +func TestEditGateRuntimeReadsEveryRequiredKindAndRetainsLiveTiers(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + // A one-record dispatch control, never accepted as the100-edit release gate. + plan.GateModes = true + plan.Steps[0].ProbeMode = "json" + step := plan.Steps[0] + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\nfunc Example() int { return 1 }\n"+c.EditAnchor+"\n"), 0o600); err != nil { + t.Fatal(err) + } + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(frame stubFrame) (any, *rpcError) { + if frame.Method != "tools/call" { + return stubContent(map[string]any{}), nil + } + args, _ := frame.Params["arguments"].(map[string]any) + if frame.Params["name"] == "edit" && args["operation"] == "file" { + return stubContent(map[string]any{"mutation_receipt": "receipt-a", "readback_exact": true}), nil + } + if frame.Params["name"] == "change" && args["operation"] == "receipt" { + return stubContent(map[string]any{"receipt": "receipt-a", "mutation_id": step.MutationID, "graph_status_terminal": true, "graph_status": "fresh", "ack_at": "2026-01-01T00:00:00Z", "file_graph_at": "2026-01-01T00:00:00Z", "relations_at": "2026-01-01T00:00:00.001Z", "derived_at": "2026-01-01T00:00:00.002Z", "tier_state": map[string]any{"file_graph": "fresh", "relations": "fresh", "derived": "fresh"}}), nil + } + // Incorrect empty reads must stay errors, even when coverage is complete. + return stubContent(map[string]any{}), nil + }) + run.requireAccounting = false + run.doEdit(ctx, step) + state := run.states[0] + state.finishRaw() + if len(state.record.Readbacks) == 0 || state.record.Readbacks[0].Kind != "source" { + t.Error("edit + one read must end at the first symbol-source/summary response, not read_file") + } + seen := map[string]bool{} + for _, back := range state.record.Readbacks { + seen[back.Kind] = true + } + for _, kind := range []string{"file", "source", "summary", "search_visible", "text"} { + if !seen[kind] { + t.Errorf("gate never dispatched required same-editor immediate %s readback", kind) + } + } + if state.record.Readback.Value != nil || state.record.Outcome == "complete" { + t.Error("incorrect readbacks became a successful gate observation") + } + data, err := json.Marshal(state.record) + if err != nil { + t.Fatal(err) + } + var wire struct { + Tiers *struct { + ACK string `json:"ack_at"` + File string `json:"file_graph_at"` + Relations string `json:"relations_at"` + Derived string `json:"derived_at"` + States map[string]string `json:"tier_state"` + } `json:"mutation_tiers"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if wire.Tiers == nil { + t.Error("actual mutation_status tier stamps were discarded") + } else if wire.Tiers.ACK == "" || wire.Tiers.File != wire.Tiers.ACK || wire.Tiers.Relations == "" || wire.Tiers.Derived == "" { + t.Errorf("equal-clock causal stamps were lost or invented: %+v", wire.Tiers) + } + if wire.Tiers != nil { + for _, tier := range []string{"file_graph", "relations", "derived"} { + if wire.Tiers.States[tier] != "fresh" { + t.Errorf("live tier state %s was discarded: %+v", tier, wire.Tiers.States) + } + } + } +} + +func TestEditGateFreshnessPrefixSurvivesActualWireDecoding(t *testing.T) { + for _, mode := range []string{"compact", "toon", "gcx"} { + t.Run(mode, func(t *testing.T) { + for _, state := range []string{"pending", "failed"} { + var text string + switch mode { + case "compact": + text = "relations: " + state + " (2 files)\nfunction Added fixture/main.go:5\n" + case "toon": + encoded, err := toon.Marshal(map[string]any{"nodes": []any{map[string]any{"id": "fixture/main.go::Added"}}, "total": 1, "truncated": false}) + if err != nil { + t.Fatal(err) + } + text = "relations: " + state + " (2 files)\n" + string(encoded) + case "gcx": + text = "GCX1 tool=get_file_summary fields=id graph_freshness=" + state + " freshness_scope=relations pending_file_count=2\n# relations: " + state + " (2 files)\nfixture/main.go::Added\n" + } + body, err := decodeEditProbeOutput(outputWire(text, map[string]any{"graph_freshness": "fresh"}), mode, "read.summary") + if mode == "gcx" { + if !errors.Is(err, errEditOutputGCXUnavailable) { + t.Fatalf("GCX payload got unrequested decoder credit: %v", err) + } + } else if err != nil { + t.Errorf("actual %s freshness rider rejected: %v", mode, err) + continue + } + if body["graph_freshness"] != state || body["freshness_scope"] != "relations" || body["pending_file_count"] != json.Number("2") { + t.Errorf("discarded actual producer metadata (%s/%s): %+v", mode, state, body) + } + if editProbeClaimsFresh(body) { + t.Error("pending/failed text rider became a fresh claim") + } + } + }) + } +} + +func TestEditGateRuntimeInterpretsEachRecordsActualProbeSurface(t *testing.T) { + for _, mode := range []string{"toon", "compact", "facade", "cli"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + plan.GateModes = true + plan.Steps[0].ProbeMode = mode + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\n"+c.EditAnchor+"\n"), 0o600); err != nil { + t.Fatal(err) + } + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(stubFrame) (any, *rpcError) { return stubContent(map[string]any{}), nil }) + state := run.states[0] + defer state.finishRaw() + now := time.Now() + state.started, state.acknowledged = now.Add(-time.Millisecond), now.Add(-time.Microsecond) + oracle := editProbeOracle{name: "summary", call: request{Tool: "read", Operation: "summary", Arguments: map[string]any{"operation": "summary"}}} + oracle.call = editProbeWithOutput(oracle.call, mode) + var raw json.RawMessage + switch mode { + case "toon": + text, err := toon.Marshal(map[string]any{"fresh": true, "nodes": []any{}}) + if err != nil { + t.Fatal(err) + } + raw = outputWire(string(text), nil) + case "compact": + raw = outputWire("relations: failed (1 files)\nfunction "+plan.Steps[0].Marker+" fixture/"+c.File+":5\n", nil) + case "facade": + raw = outputWire(`{"fresh":true,"nodes":[]}`, nil) // Missing surface identity must not borrow JSON credit. + case "cli": + raw = json.RawMessage(`{"fresh":true,"nodes":[]}`) + } + if err := run.observeProbe(state, oracle, now, raw, wireCallIdentity{WriteStarted: now, Finished: now.Add(time.Microsecond)}, nil, false); err != nil { + t.Fatal(err) + } + got := run.modeCounts[mode] + if got.answers != 1 { + t.Errorf("actual %s answer was not counted in its record's mode: %+v", mode, run.modeCounts) + } + if run.modeCounts["json"].answers != 0 { + t.Error("non-JSON record borrowed JSON observations") + } + switch mode { + case "facade": + if got.decodeFailures != 1 || got.claimsFresh != 0 { + t.Error("missing facade identity was interpreted as a fresh JSON answer") + } + case "toon", "cli": + if got.decodeFailures != 0 || got.stale != 1 { + t.Error("actual decoded stale/fresh claim was discarded or attributed to another format") + } + case "compact": + if got.decodeFailures != 0 || got.claimsFresh != 0 { + t.Error("explicit failed metadata became a decode error or fresh claim") + } + } + }) + } +} + +func TestEditGateRuntimeRequestsTheAssignedWireForEachRecord(t *testing.T) { + for _, mode := range []string{"toon", "compact"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + plan.GateModes = true + plan.Steps[0].ProbeMode = mode + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\n"+c.EditAnchor+"\n"), 0o600); err != nil { + t.Fatal(err) + } + seen := make(chan map[string]any, 1) + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(frame stubFrame) (any, *rpcError) { + args, _ := frame.Params["arguments"].(map[string]any) + if frame.Method == "tools/call" && frame.Params["name"] == "read" && args["operation"] == "summary" { + output, _ := args["output"].(map[string]any) + select { + case seen <- output: + default: + } + } + return stubContent(map[string]any{}), nil // JSON cannot stand in for either requested wire. + }) + state := run.states[0] + defer state.finishRaw() + run.probe(ctx, cancel, state) + var output map[string]any + select { + case output = <-seen: + case <-ctx.Done(): + t.Error("assigned-mode request never reached actual socket dispatch") + } + cancel() + state.probes.Wait() + if mode == "toon" && output["format"] != "toon" { + t.Errorf("record sent%v instead of its TOON wire", output) + } + if mode == "compact" && output["compact"] != true { + t.Errorf("record sent%v instead of its compact producer request", output) + } + }) + } +} + +func TestEditGateModeCoverageCountsDistinctAssignedEditsAndActualTools(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 100) + if err != nil { + t.Fatal(err) + } + plan.GateModes = true + plan.Steps[0].ProbeMode, plan.Steps[1].ProbeMode = "json", "compact" + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\n"+c.EditAnchor+"\n"), 0o600); err != nil { + t.Fatal(err) + } + run := newEditProbeStubRun(t, ctx, c, plan, dir, 2, func(stubFrame) (any, *rpcError) { return stubContent(map[string]any{}), nil }) + for _, state := range run.states { + defer state.finishRaw() + } + now := time.Now() + for _, state := range run.states { + state.started, state.acknowledged = now.Add(-2*time.Millisecond), now.Add(-time.Millisecond) + } + identity := wireCallIdentity{WriteStarted: now, Finished: now.Add(time.Microsecond)} + summary := editProbeOracle{name: "summary", call: request{Tool: "read", Operation: "summary"}} + for range 2 { + if err := run.observeProbe(run.states[0], summary, now, outputWire(`{"fresh":true,"nodes":[]}`, nil), identity, nil, false); err != nil { + t.Fatal(err) + } + } + // Compact does not implement source. Its real JSON fallback must not + // lend another assigned edit or freshness claim to the JSON cell. + source := editProbeOracle{name: "source", call: request{Tool: "read", Operation: "source"}} + if err := run.observeProbe(run.states[1], source, now, outputWire(`{"fresh":true,"nodes":[]}`, nil), identity, nil, false); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(editProbeOutputCoverage("gate", run.modeCounts)) + if err != nil { + t.Fatal(err) + } + var coverage map[string]struct { + Observed *int `json:"observed_edits"` + Fresh int `json:"claims_fresh"` + Tools map[string]struct { + Answers int `json:"probe_answers"` + Edits int `json:"observed_edits"` + } `json:"tools"` + } + if err := json.Unmarshal(data, &coverage); err != nil { + t.Fatal(err) + } + jsonMode := coverage["json"] + if jsonMode.Observed == nil || *jsonMode.Observed != 1 || jsonMode.Fresh != 2 { + t.Errorf("repeated probes or another assigned mode borrowed edit/freshness credit: %s", data) + } + if tool := jsonMode.Tools["read.summary"]; tool.Answers != 2 || tool.Edits != 1 { + t.Errorf("actual dispatched tool census missing or inflated: %s", data) + } + if compact := coverage["compact"]; compact.Observed == nil || *compact.Observed != 0 { + t.Errorf("JSON fallback credited as compact: %s", data) + } + if _, exists := coverage["facade"]; !exists { + t.Errorf("facade mode omitted entirely: %s", data) + } +} + +func TestEditGateCompactCoverageRequiresPostACKSemanticAndRefusalProof(t *testing.T) { + for _, variant := range []string{"pre_ack", "wrong_content", "correct_content", "decode_failure", "unsupported_fallback"} { + t.Run(variant, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + plan.GateModes = true + plan.Steps[0].ProbeMode = "compact" + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\n"+c.EditAnchor+"\n"), 0o600); err != nil { + t.Fatal(err) + } + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(stubFrame) (any, *rpcError) { return stubContent(map[string]any{}), nil }) + state := run.states[0] + defer state.finishRaw() + now := time.Now() + state.started, state.acknowledged = now.Add(-3*time.Millisecond), now.Add(-time.Millisecond) + sent := now + if variant == "pre_ack" { + sent = now.Add(-2 * time.Millisecond) + } + oracle := editProbeOracle{name: "summary", call: editProbeWithOutput(request{Tool: "read", Operation: "summary"}, "compact")} + row := "function " + state.step.Marker + " fixture/" + c.File + ":5\n" + raw := outputWire(row, nil) + if variant == "wrong_content" { + raw = outputWire("function Wrong fixture/"+c.File+":5\n", nil) + } + if variant == "decode_failure" { + raw = outputWire(`{"fresh":true}`, nil) + } + if variant == "unsupported_fallback" { + oracle = editProbeOracle{name: "source", call: request{Tool: "read", Operation: "source"}} + raw = outputWire(`{}`, nil) + } + if err := run.observeProbe(state, oracle, sent, raw, wireCallIdentity{WriteStarted: sent, Finished: sent.Add(time.Microsecond)}, nil, false); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(editProbeOutputCoverage("gate", run.modeCounts)) + if err != nil { + t.Fatal(err) + } + var modes map[string]struct { + Tools map[string]struct { + Correct *int `json:"correct_edits"` + Refused *int `json:"refused_edits"` + Fallback *int `json:"fallback_answers"` + State string `json:"state"` + } `json:"tools"` + } + if err := json.Unmarshal(data, &modes); err != nil { + t.Fatal(err) + } + if variant == "unsupported_fallback" { + tool := modes["compact"].Tools["read.source"] + if tool.State != "UNSUPPORTED" || tool.Refused == nil || *tool.Refused != 1 || tool.Fallback == nil || *tool.Fallback != 1 { + t.Errorf("actual unsupported fallback/refusal was lost: %s", data) + } + return + } + tool := modes["compact"].Tools["read.summary"] + want := 0 + if variant == "correct_content" { + want = 1 + } + if tool.Correct == nil || *tool.Correct != want { + t.Errorf("compact semantic witness borrowed pre-ACK, wrong or undecoded content: %s", data) + } + }) + } +} + +func TestEditGateRetainsActualAdmittedTargetLineCount(t *testing.T) { + for _, lines := range []int{9999, 10000, 10001} { + t.Run(fmt.Sprint(lines), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + plan.GateModes = true + plan.Steps[0].ProbeMode = "json" + dir := t.TempDir() + source := "package fixture\r\n" + strings.Repeat("// padding\r\n", lines-2) + c.EditAnchor + file := filepath.Join(dir, c.File) + if err := os.WriteFile(file, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(stubFrame) (any, *rpcError) { return stubContent(map[string]any{}), nil }) + defer run.states[0].finishRaw() + // Later bytes cannot promote an initially small target into the + // separate large-file admission class. + if err := os.WriteFile(file, []byte(source+"\n"+strings.Repeat("// later\n", 10000)), 0o600); err != nil { + t.Fatal(err) + } + _, _, finish, _ := run.beginEdit(ctx, plan.Steps[0]) + finish() + data, err := json.Marshal(run.states[0].record) + if err != nil { + t.Fatal(err) + } + var wire struct { + Lines *int `json:"admitted_target_lines"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if wire.Lines == nil || *wire.Lines != lines { + t.Errorf("target class was omitted or borrowed from later bytes: %s", data) + } + }) + } +} diff --git a/bench/load/edit_gate_seed.go b/bench/load/edit_gate_seed.go new file mode 100644 index 000000000..1a7905152 --- /dev/null +++ b/bench/load/edit_gate_seed.go @@ -0,0 +1,34 @@ +package main + +import ( + "debug/buildinfo" + "errors" + "fmt" + "strings" +) + +// Faults are reachable only through the explicit standalone gate option and a +// binary built with the seed tag. Ambient GORTEX_* values remain filtered out. +func editGateSeedEnvironment(o runOptions) ([]string, error) { + if o.EditGateSeed == "" { + return nil, nil + } + if err := validateRunOptions(o); err != nil { + return nil, err + } + info, err := buildinfo.ReadFile(o.Binary) + if err != nil { + return nil, fmt.Errorf("read edit gate seed binary metadata: %w", err) + } + for _, setting := range info.Settings { + if setting.Key != "-tags" { + continue + } + for tag := range strings.FieldsFuncSeq(setting.Value, func(r rune) bool { return r == ',' || r == ' ' }) { + if tag == "gortex_edit_gate_seed" { + return []string{"GORTEX_EDIT_GATE_SEED=" + o.EditGateSeed}, nil + } + } + } + return nil, errors.New("edit gate fault requires binary built with gortex_edit_gate_seed tag") +} diff --git a/bench/load/edit_gate_seed_test.go b/bench/load/edit_gate_seed_test.go new file mode 100644 index 000000000..20a72a0f8 --- /dev/null +++ b/bench/load/edit_gate_seed_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestEditGateSeedExplicitOptIn(t *testing.T) { + base := []string{"--binary", "unused", "--manifest", "unused", "--scratch", t.TempDir(), "--scenario", editProbeScenario, "--agents", "0", "--edit-agents", "4", "--worktrees", "0"} + for _, mode := range []string{"ack-wait", "closed-read-gate"} { + t.Run(mode, func(t *testing.T) { + args := append(append([]string{}, base...), "--edit-gate-modes", "--edit-gate-seed", mode) + if _, err := parseRun(args); err != nil { + t.Fatalf("explicit gate-only seed unavailable: %v", err) + } + }) + } + for _, args := range [][]string{ + append(append([]string{}, base...), "--edit-gate-seed", "ack-wait"), + append(append([]string{}, base...), "--edit-gate-modes", "--edit-gate-seed", "unregistered"), + } { + if _, err := parseRun(args); err == nil { + t.Fatal("seed accepted outside the explicit closed gate contract") + } + } + t.Setenv("GORTEX_EDIT_GATE_SEED", "ack-wait") + for _, entry := range privateEnv(t.TempDir(), "owned.sock", "owned.gitconfig") { + if strings.HasPrefix(entry, "GORTEX_EDIT_GATE_SEED=") { + t.Fatal("ambient seed leaked into ordinary private daemon") + } + } +} + +func TestEditGateSeedVerifiesActualBinaryAndOwnsEnvironment(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main() {}\n"), 0600); err != nil { + t.Fatal(err) + } + options := runOptions{Binary: filepath.Join(dir, "seed-bin"), Manifest: "unused", Scratch: dir, Scenario: editProbeScenario, Editors: 4, EditCount: 100, EditGateModes: true, EditGateSeed: "ack-wait", Duration: 1, Deadline: 1, WarmTimeout: 1, StopTimeout: 1, Profile: "agent", SweepRepetitions: 1, SweepBudget: 1} + for _, tags := range []string{"gortex_edit_gate_seed_suffix", "", "extra,gortex_edit_gate_seed"} { + cmd := exec.CommandContext(t.Context(), "go", "build", "-tags="+tags, "-o", options.Binary, filepath.Join(dir, "main.go")) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build metadata fixture: %v: %s", err, out) + } + env, err := editGateSeedEnvironment(options) + if tags == "extra,gortex_edit_gate_seed" { + if err != nil || !reflect.DeepEqual(env, []string{"GORTEX_EDIT_GATE_SEED=ack-wait"}) { + t.Fatalf("tagged binary seed environment=%v err=%v", env, err) + } + } else if err == nil || len(env) != 0 { + t.Fatalf("untagged/prefix-only binary accepted seed: %v %v", env, err) + } + } + options.Binary = "missing" + options.EditGateSeed = "" + if env, err := editGateSeedEnvironment(options); err != nil || len(env) != 0 { + t.Fatalf("ordinary run unexpectedly inspects metadata or enables a seed: %v %v", env, err) + } + options.EditGateSeed = "ack-wait" + if _, err := editGateSeedEnvironment(options); err == nil { + t.Fatal("missing executable accepted") + } +} + +func TestEditGateSeedRefusesBeforeAllocatingPrivateState(t *testing.T) { + options := runOptions{Binary: "missing", Manifest: "unused", Scratch: t.TempDir(), Scenario: editProbeScenario, Editors: 4, EditCount: 100, EditGateModes: true, EditGateSeed: "ack-wait", Duration: 1, Deadline: 1, WarmTimeout: 1, StopTimeout: 1, Profile: "agent", SweepRepetitions: 1, SweepBudget: 1} + root := t.TempDir() + _, _, artifacts, _, _, err := runPhase(t.Context(), options, editProbeScenario, nil, "", root) + if err == nil || len(artifacts) != 0 { + t.Fatalf("untagged seed reached private state: artifacts=%v err=%v", artifacts, err) + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatalf("refused seed allocated state: %v %v", entries, err) + } +} diff --git a/bench/load/edit_output_decode.go b/bench/load/edit_output_decode.go index ffc12113d..d1f6bb4f2 100644 --- a/bench/load/edit_output_decode.go +++ b/bench/load/edit_output_decode.go @@ -31,6 +31,13 @@ const editProbeTOONWireKey = "\x00load.toon.wire" var errEditOutputGCXUnavailable = errors.New("GCX output unavailable: no GCX decoder, and read_file answers a GCX request with TOON") func decodeEditProbeOutput(raw json.RawMessage, format, operation string) (map[string]any, error) { + facade := format == "facade" + if facade { + if err := verifyEditProbeFacade(raw, operation); err != nil { + return nil, err + } + format = "json" + } switch format { case "", "json": case "toon": @@ -39,7 +46,8 @@ func decodeEditProbeOutput(raw json.RawMessage, format, operation string) (map[s case "cli": return decodeEditProbeCLIOutput(raw) case "gcx": - return nil, errEditOutputGCXUnavailable + fields, err := editProbeGCXTierFields(raw, operation) + return fields, errors.Join(errEditOutputGCXUnavailable, err) default: return nil, errors.New("unsupported edit output mode") } @@ -47,6 +55,13 @@ func decodeEditProbeOutput(raw json.RawMessage, format, operation string) (map[s if err != nil { return nil, err } + var tierFields map[string]any + if format == "toon" { + text, tierFields, err = editProbeTierPrefix(text) + if err != nil { + return nil, err + } + } if format == "" || format == "json" { decoder := json.NewDecoder(bytes.NewReader(text)) decoder.UseNumber() @@ -57,6 +72,12 @@ func decodeEditProbeOutput(raw json.RawMessage, format, operation string) (map[s if err := decoder.Decode(&body); err != nil { return nil, err } + if body == nil { + return nil, errors.New("requested JSON must return an object, not null") + } + if facade { + body[editProbeFacadeWireKey] = editProbeFacadeWire{operation: operation} + } return body, nil } if json.Valid(text) { @@ -85,6 +106,9 @@ func decodeEditProbeOutput(raw json.RawMessage, format, operation string) (map[s if err := decoder.Decode(&body); err != nil { return nil, err } + if err := editProbeKeepTierFields(body, tierFields); err != nil { + return nil, err + } body[editProbeTOONWireKey] = editProbeTOONWire{operation: operation} return body, nil } @@ -158,6 +182,10 @@ func decodeEditProbeCompactOutput(raw json.RawMessage, operation string) (map[st if err != nil { return nil, err } + text, tierFields, err := editProbeTierPrefix(text) + if err != nil { + return nil, err + } if json.Valid(text) { return nil, errors.New("requested compact text returned JSON; no format credit") } @@ -189,7 +217,11 @@ func decodeEditProbeCompactOutput(raw json.RawMessage, operation string) (map[st return nil, fmt.Errorf("compact line outside the %s grammar: %q", operation, line) } } - return map[string]any{editProbeCompactKey: answer}, nil + body := map[string]any{editProbeCompactKey: answer} + if err := editProbeKeepTierFields(body, tierFields); err != nil { + return nil, err + } + return body, nil } // `gortex call --format json` prints the tool's first text payload as JSON, diff --git a/bench/load/edit_probe_cli.go b/bench/load/edit_probe_cli.go index 292e16483..e7d6ecd42 100644 --- a/bench/load/edit_probe_cli.go +++ b/bench/load/edit_probe_cli.go @@ -33,14 +33,18 @@ func (b *editProbeCappedBuffer) Write(data []byte) (int, error) { // The edit ACK, readbacks and receipt status stay MCP JSON; only oracle // requests go through the CLI, with the same lowered facade arguments. func (r *editProbeRuntime) performCLI(ctx context.Context, s *editProbeState, req request, purpose string, stream int) (json.RawMessage, wireCallIdentity, error) { + repo, err := r.repoFor(s.step) + if err != nil { + return nil, wireCallIdentity{}, err + } budget := min(r.options.Deadline, 20*time.Second) callCtx, cancel := context.WithTimeout(ctx, budget) defer cancel() - args, err := json.Marshal(dispatchArgs(req, r.p.Repos[0])) + args, err := json.Marshal(dispatchArgs(req, repo)) if err != nil { return nil, wireCallIdentity{}, err } - argv := []string{"--config", r.p.Config, "call", req.Tool, "--json", string(args), "--format", "json", "--index", r.p.Repos[0].Path, "--quiet"} + argv := []string{"--config", r.p.Config, "call", req.Tool, "--json", string(args), "--format", "json", "--index", repo.Path, "--quiet"} cmd := exec.CommandContext(callCtx, r.p.Binary, argv...) configureOwnedProcess(cmd) cmd.Cancel = func() error { return killOwnedProcess(cmd) } diff --git a/bench/load/edit_probe_cli_test.go b/bench/load/edit_probe_cli_test.go index 0b81777ae..c82507b32 100644 --- a/bench/load/edit_probe_cli_test.go +++ b/bench/load/edit_probe_cli_test.go @@ -177,3 +177,48 @@ func TestEditProbeCLIModeRefusesAbsenceKinds(t *testing.T) { } } } + +func TestEditGateCLIUsesItsAssignedCheckoutAndRejectsUnknownEditor(t *testing.T) { + for _, invalid := range []bool{false, true} { + t.Run(fmt.Sprint(invalid), func(t *testing.T) { + argvFile := filepath.Join(t.TempDir(), "argv.json") + run, state, oracle := editProbeCLIFixture(t, 10*time.Second, "LOAD_EDIT_PROBE_CLI_FIXTURE=reply", "LOAD_EDIT_PROBE_CLI_ARGV="+argvFile, "LOAD_EDIT_PROBE_CLI_STDOUT={}") + defer state.finishRaw() + second := run.p.Repos[0] + second.Path, second.Prefix, second.Corpus.Label = filepath.Join(run.p.Root, "second-clone"), "second", "second" + run.p.Repos = append(run.p.Repos, second) + run.editors = []*socketClient{{cwd: second.Path}} + if invalid { + state.step.Editor = 7 + } + _, _, err := run.performCLI(t.Context(), state, oracle.call, "oracle:summary", 11) + if invalid { + if err == nil { + t.Error("unknown route started a CLI process against the first repository") + } + if _, err := os.Stat(argvFile); !errors.Is(err, os.ErrNotExist) { + t.Error("foreign editor launched a process") + } + return + } + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(argvFile) + if err != nil { + t.Fatal(err) + } + var argv []string + if err := json.Unmarshal(raw, &argv); err != nil { + t.Fatal(err) + } + lowered, err := json.Marshal(dispatchArgs(oracle.call, second)) + if err != nil { + t.Fatal(err) + } + if len(argv) != 11 || argv[9] != second.Path || argv[5] != string(lowered) { + t.Errorf("CLI borrowed another checkout's index/arguments: %q", argv) + } + }) + } +} diff --git a/bench/load/edit_probe_move.go b/bench/load/edit_probe_move.go index 1100b5371..632cd5135 100644 --- a/bench/load/edit_probe_move.go +++ b/bench/load/edit_probe_move.go @@ -134,6 +134,8 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP } out.RPCID = "rpc-" + strings.TrimPrefix(refs.OperationID, "move-") ackHash, session := "", "" + queuedRows := 0 + queuedWaitRequired, queuedWaitMeasured := false, false for _, ref := range refs.Files { row := statuses[ref.Role] file := editProbeMoveFileAccounting{Path: ref.Path, Role: ref.Role, Receipt: ref.Receipt} @@ -146,7 +148,13 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP if row["move_write_verdict"] != "all_writes_completed" || row["file_write_status"] != "committed" || row["writer_return_observed"] != true { fail("move source writes not confirmed") } - if row["requested_background_state"] != "not_applicable_direct_reconcile" || row["requested_background"] != nil { + queued := row["direct_reconcile_state"] == "unavailable_queued_publication" || row["read_assisted_reconcile_state"] == "unavailable_queued_publication" + if queued { + queuedRows++ + if row["direct_reconcile_state"] != "unavailable_queued_publication" || row["read_assisted_reconcile_state"] != "unavailable_queued_publication" || row["direct_reconcile"] != nil || row["read_assisted_reconcile"] != nil || row["requested_background"] != nil { + fail("queued move contradicts unavailable publication windows") + } + } else if row["requested_background_state"] != "not_applicable_direct_reconcile" || row["requested_background"] != nil { fail("move direct route/background applicability mismatch") } blob, _ := row["new_sha"].(string) @@ -173,6 +181,16 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP for _, name := range []string{"caller_result", "disk_write", "edit_control", "middleware", "parse", "publish", "reindex_wait", "reply_prepare"} { value, present := phases[name] if name == "publish" || name == "reindex_wait" { + if queued { + if !moveQueuedAcknowledgementPhase(row, name, value, present, states[name], applicability[name], counts[name]) { + fail("queued move scheduler A phase unavailable: " + name) + } + if name == "reindex_wait" { + queuedWaitRequired = queuedWaitRequired || applicability[name] == "applicable" + queuedWaitMeasured = queuedWaitMeasured || value != nil + } + continue + } count, _ := counts[name].(map[string]any) started, se := strconvEditProbeUint(count["started"]) completed, ce := strconvEditProbeUint(count["completed"]) @@ -200,6 +218,25 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP if timing["scope"] == "move_file_write" && timing["clock"] == "monotonic" && timing["state"] == "measured" && timing["outcome"] == "committed" && se == nil && re == nil && we == nil && returned >= start && wall == returned-start { file.WriteStartedNS, file.WriteReturnedNS, file.WriteWindowNS = &start, &returned, &wall } + if out.A != nil && returned > out.A.WallNS { + fail("move write exceeds actual acknowledgement bounds") + } + original, _ := row["move_request_cause"].(map[string]any) + originalSession, _ := original["session"].(string) + originalKey, _ := original["request_id_key"].(string) + if originalSession == "" || originalKey != "n:"+strconv.FormatInt(wireID, 10) { + fail("original move wire cause unavailable") + } + if session == "" { + session = originalSession + } else if session != originalSession { + fail("move per-file original causes came from different sessions") + } + if queued { + file.Route = "queued_publication_ack_write_only" + out.Files = append(out.Files, file) + continue + } route, scope := "direct_reconcile", "move_direct_reconcile" coalesced := row["direct_reconcile_state"] == "observed" && row["read_assisted_reconcile_state"] == "observed" var directView map[string]any @@ -225,9 +262,7 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP if row[route+"_state"] != "observed" || direct["scope"] != scope || direct["clock"] != "monotonic" || direct["accepted_blob_sha"] != blob || direct["additive_with_acknowledgement"] != false || oe != nil || owner == 0 || de != nil || dwall <= 0 || ae != nil { fail("move actual accepted owner/window unavailable") } - if out.A != nil && returned > out.A.WallNS { - fail("move write exceeds actual acknowledgement bounds") - } + if !movePublicationEndpoints(direct, route, dwall, returned, toAcceptance, out.A) { fail("move actual acceptance/write/window endpoints disagree") } @@ -235,17 +270,6 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP fail("coalesced direct work exceeds acknowledgement bounds") } file.OwnerGeneration = owner - original, _ := row["move_request_cause"].(map[string]any) - originalSession, _ := original["session"].(string) - originalKey, _ := original["request_id_key"].(string) - if originalSession == "" || originalKey != "n:"+strconv.FormatInt(wireID, 10) { - fail("original move wire cause unavailable") - } - if session == "" { - session = originalSession - } else if session != originalSession { - fail("move per-file original causes came from different sessions") - } request, _ := direct["request_cause"].(map[string]any) requestSession, _ := request["session"].(string) requestKey, _ := request["request_id_key"].(string) @@ -318,12 +342,52 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP } out.Files = append(out.Files, file) } + if queuedRows > 0 && (queuedRows != len(refs.Files) || !queuedWaitRequired && queuedWaitMeasured) { + fail("queued move route does not explain shared acknowledgement execution") + } if len(out.Reasons) == 0 { - out.State = "observed_move_windows_checks_pass" + if queuedRows == len(refs.Files) { + out.State = "qualified_queued_ack_write_only" + fail("queued publication: physical P/B and direct/R windows are unavailable; shared ACK and file writes only") + } else { + out.State = "observed_move_windows_checks_pass" + } } return out } +// Applicability belongs to each file, while execution/counts describe the one +// shared RPC. A failed or fast file can share a real wait for its sibling; +// that wait never becomes a per-file publication or background observation. +func moveQueuedAcknowledgementPhase(row map[string]any, name string, value any, present bool, state, applicability, rawCounts any) bool { + background := row["requested_background_state"] + if background != "admitted" && background != "admission_failed" { + return false + } + if name == "publish" { + if applicability != "applicable" || state != "measured" || value == nil { + return false + } + } else if background == "admission_failed" { + if applicability != "not_observed" { + return false + } + } else if applicability != "applicable" && applicability != "not_applicable_fast_ack" { + return false + } + count, _ := rawCounts.(map[string]any) + started, se := strconvEditProbeUint(count["started"]) + completed, ce := strconvEditProbeUint(count["completed"]) + na, ne := strconvEditProbeUint(count["not_applicable"]) + if !present || se != nil || ce != nil || ne != nil || na != 0 { + return false + } + if value == nil { + return applicability != "applicable" && state == "unobserved" && started == 0 && completed == 0 + } + return state == "measured" && started > 0 && completed == started +} + func (r editProbeMoveReferences) validateReferences() error { if r.ReferenceError != "" { return errors.New(r.ReferenceError) diff --git a/bench/load/edit_probe_move_queued_test.go b/bench/load/edit_probe_move_queued_test.go new file mode 100644 index 000000000..4c5f92ade --- /dev/null +++ b/bench/load/edit_probe_move_queued_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" +) + +func moveQueuedFixture(t *testing.T, mode string) (editProbeMoveReferences, map[string]map[string]any) { + t.Helper() + refs, rows := moveAccountingFixture(t) + for _, row := range rows { + delete(row, "direct_reconcile") + delete(row, "read_assisted_reconcile") + row["direct_reconcile_state"], row["read_assisted_reconcile_state"] = "unavailable_queued_publication", "unavailable_queued_publication" + row["requested_background_state"] = "admitted" + app := map[string]any{"publish": "applicable", "reindex_wait": "applicable"} + row["acknowledgement_phase_applicability"] = app + ack := row["acknowledgement"].(map[string]any) + phases := ack["observed_execution_ms"].(map[string]any) + states := ack["phase_states"].(map[string]any) + counts := ack["counts"].(map[string]any) + for _, name := range []string{"publish", "reindex_wait"} { + phases[name], states[name] = json.Number("10"), "measured" + counts[name] = map[string]any{"started": json.Number("1"), "completed": json.Number("1"), "not_applicable": json.Number("0")} + } + ack["caller_response_ready_ms"] = json.Number("80") + if mode != "admitted_wait" { + ack["caller_response_ready_ms"] = json.Number("70") + phases["reindex_wait"], states["reindex_wait"] = nil, "unobserved" + counts["reindex_wait"] = map[string]any{"started": json.Number("0"), "completed": json.Number("0"), "not_applicable": json.Number("0")} + app["reindex_wait"] = "not_applicable_fast_ack" + } + if mode == "admission_failed" { + row["requested_background_state"] = "admission_failed" + app["reindex_wait"] = "not_observed" + counts["reindex_wait"].(map[string]any)["not_applicable"] = json.Number("0") + } + } + return refs, rows +} + +func TestMoveQueuedConsumerKeepsACKAndWriteQualifiedWithoutPublicationCredit(t *testing.T) { + for _, mode := range []string{"admitted_wait", "admitted_fast", "admission_failed"} { + t.Run(mode, func(t *testing.T) { + refs, rows := moveQueuedFixture(t, mode) + got := checkEditProbeMoveAccounting(rows, refs, 17) + if got.State != "qualified_queued_ack_write_only" || got.A == nil || len(got.Files) != 2 { + t.Fatalf("known queued route lost its qualified actual ACK/write observations: %+v", got) + } + if len(got.Reasons) == 0 { + t.Fatal("queued publication must retain the missing physical P/B and direct/R qualification") + } + for _, file := range got.Files { + if file.D != nil || file.R != nil || file.WriteWindowNS == nil || !strings.HasPrefix(file.Route, "queued_publication") { + t.Fatalf("queued row borrowed or invented a publication window: %+v", file) + } + } + }) + } +} + +func TestMoveQueuedConsumerRefusesContradictionsAndForeignACKOrCause(t *testing.T) { + for _, fault := range []string{"direct_map", "background_map", "wire_cause", "foreign_ack", "write_window", "publish_phase", "missing_route", "wait_count", "invented_fast_wait", "outside_ack", "split_session"} { + t.Run(fault, func(t *testing.T) { + refs, rows := moveQueuedFixture(t, "admitted_wait") + row := rows["source"] + switch fault { + case "direct_map": + row["direct_reconcile"] = map[string]any{"window_ns": json.Number("0")} + case "background_map": + row["requested_background"] = map[string]any{"background_wall_ms": json.Number("0")} + case "wire_cause": + row["move_request_cause"].(map[string]any)["request_id_key"] = "n:18" + case "foreign_ack": + row["acknowledgement"].(map[string]any)["rpc_id"] = "foreign" + case "write_window": + delete(row, "write_timing") + case "publish_phase": + row["acknowledgement"].(map[string]any)["observed_execution_ms"].(map[string]any)["publish"] = nil + case "wait_count": + row["acknowledgement"].(map[string]any)["counts"].(map[string]any)["reindex_wait"].(map[string]any)["completed"] = json.Number("0") + case "invented_fast_wait": + for _, r := range rows { + r["acknowledgement_phase_applicability"].(map[string]any)["reindex_wait"] = "not_applicable_fast_ack" + } + case "outside_ack": + row["write_timing"].(map[string]any)["returned_ns"] = json.Number("100000000") + row["write_timing"].(map[string]any)["window_ns"] = json.Number("99000000") + case "split_session": + row["move_request_cause"].(map[string]any)["session"] = "foreign-session" + case "missing_route": + delete(row, "requested_background_state") + } + got := checkEditProbeMoveAccounting(rows, refs, 17) + if got.State == "qualified_queued_ack_write_only" || got.State == "observed_move_windows_checks_pass" { + t.Fatalf("invalid queued evidence credited: %s %+v", fault, got) + } + }) + } +} + +func TestMoveQueuedConsumerSharesRealACKWaitWithoutAssigningItToFailedSibling(t *testing.T) { + refs, rows := moveQueuedFixture(t, "admitted_wait") + rows["target"]["requested_background_state"] = "admission_failed" + rows["target"]["acknowledgement_phase_applicability"].(map[string]any)["reindex_wait"] = "not_observed" + got := checkEditProbeMoveAccounting(rows, refs, 17) + if got.State != "qualified_queued_ack_write_only" || got.A == nil { + t.Fatalf("shared real ACK wait lost or became per-file publication: %+v", got) + } + for _, file := range got.Files { + if file.D != nil || file.R != nil { + t.Fatal("shared ACK wait borrowed as publication") + } + } +} + +func TestMoveQueuedConsumerCollectorNeverMarksGraphTerminalFromQualifiedACK(t *testing.T) { + refs, rows := moveQueuedFixture(t, "admitted_wait") + socket := startStub(t, func(frame stubFrame) (any, *rpcError) { + args, _ := frame.Params["arguments"].(map[string]any) + options, _ := args["options"].(map[string]any) + for _, file := range refs.Files { + if options["receipt"] == file.Receipt { + return stubContent(rows[file.Role]), nil + } + } + return stubContent(map[string]any{"found": false}), nil + }) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + client, err := openMCP(ctx, socket, "") + if err != nil { + t.Fatal(err) + } + defer client.Close() + sink, err := os.CreateTemp(t.TempDir(), "queued-move-*.jsonl") + if err != nil { + t.Fatal(err) + } + defer sink.Close() + state := &editProbeState{client: client, sink: sink, record: completeMovePublicRecord()} + state.startRaw() + defer state.finishRaw() + run := &editProbeRuntime{options: runOptions{Deadline: time.Second}, p: &privateRun{Repos: []privateRepo{{Prefix: "fixture"}}}, phase: &Phase{Metrics: map[string]Metric{}}, origin: time.Now(), requireAccounting: true} + run.collectMoveEvidence(ctx, state, refs, 17) + if ctx.Err() != nil || state.record.MoveAccounting == nil || state.record.MoveAccounting.State != "qualified_queued_ack_write_only" || state.record.GraphTerminal || state.record.Accounting.P != nil || state.record.Accounting.B != nil { + t.Fatalf("qualified shared ACK became physical publication evidence: %+v %v", state.record, ctx.Err()) + } +} diff --git a/bench/load/edit_probe_output.go b/bench/load/edit_probe_output.go index 4e392d548..3f0ca19b7 100644 --- a/bench/load/edit_probe_output.go +++ b/bench/load/edit_probe_output.go @@ -25,41 +25,108 @@ var editProbeOutputModes = []string{"json", "toon", "compact", "cli", "gcx"} type editProbeModes map[string]editProbeModeCoverage +type editProbeToolCoverage struct { + State string `json:"state"` + Reason string `json:"reason,omitempty"` + CorrectEdits int `json:"correct_edits"` + RefusedEdits *int `json:"refused_edits,omitempty"` + FallbackAnswers *int `json:"fallback_answers,omitempty"` + ProbeAnswers int `json:"probe_answers"` + ObservedEdits int `json:"observed_edits"` + DecodeFailures int `json:"decode_failures"` + ClaimsFresh int `json:"claims_fresh"` +} + type editProbeModeCoverage struct { - State string `json:"state"` - Reason string `json:"reason,omitempty"` - ProbeAnswers int `json:"probe_answers"` - DecodeFailures int `json:"decode_failures"` - ClaimsFreshObservable bool `json:"claims_fresh_observable"` - ClaimsFresh int `json:"claims_fresh"` - StaleClaimedFresh Metric `json:"stale_claimed_fresh"` - PrecommitMismatches int `json:"precommit_fresh_mismatches_unassessed"` + ObservedEdits *int `json:"observed_edits,omitempty"` + Tools map[string]editProbeToolCoverage `json:"tools,omitempty"` + State string `json:"state"` + Reason string `json:"reason,omitempty"` + ProbeAnswers int `json:"probe_answers"` + DecodeFailures int `json:"decode_failures"` + ClaimsFreshObservable bool `json:"claims_fresh_observable"` + ClaimsFresh int `json:"claims_fresh"` + StaleClaimedFresh Metric `json:"stale_claimed_fresh"` + PrecommitMismatches int `json:"precommit_fresh_mismatches_unassessed"` } type editProbeModeCounts struct { answers, decodeFailures, claimsFresh, stale, precommit int + gate *editProbeGateCounts +} + +type editProbeGateCounts struct { + answers, decodeFailures, claimsFresh, stale, precommit int + edits map[int]bool + tools map[string]*editProbeGateToolCounts +} + +type editProbeGateToolCounts struct { + answers, decodeFailures, claimsFresh, fallbackAnswers int + correct, refused map[int]bool + edits map[int]bool } // Answers count by each oracle request's actual output mode. A mode without // answers, or whose payload cannot carry a freshness claim, reports its stale // claimed-fresh count unavailable rather than zero. func editProbeOutputCoverage(selected string, counts map[string]editProbeModeCounts) editProbeModes { - out := make(editProbeModes, len(editProbeOutputModes)) - for _, mode := range editProbeOutputModes { + modes := editProbeOutputModes + if selected == "gate" { + modes = append(append([]string(nil), modes...), "facade") + } + out := make(editProbeModes, len(modes)) + for _, mode := range modes { count := counts[mode] + gate := count.gate + if selected == "gate" { + count = editProbeModeCounts{} + if gate != nil { + count.answers, count.decodeFailures, count.claimsFresh, count.stale, count.precommit = gate.answers, gate.decodeFailures, gate.claimsFresh, gate.stale, gate.precommit + } + } coverage := editProbeModeCoverage{State: "not_selected", ProbeAnswers: count.answers, DecodeFailures: count.decodeFailures, ClaimsFreshObservable: true, ClaimsFresh: count.claimsFresh, PrecommitMismatches: count.precommit} + if selected == "gate" { + observed := 0 + coverage.Tools = map[string]editProbeToolCoverage{} + for _, tool := range []string{"read.source", "read.summary", "read.editing_context", "search.symbols", "search.text", "relations.callers", "trace.call_chain"} { + row := editProbeToolCoverage{State: "MISSING"} + if mode == "compact" && !editProbeCompactOperations[tool] { + zero := 0 + row.State, row.Reason, row.RefusedEdits, row.FallbackAnswers = "UNSUPPORTED", "harness compact-format refusal: operation has no compact producer; actual JSON fallback must be witnessed", &zero, &zero + } + coverage.Tools[tool] = row + } + if gate != nil { + observed = len(gate.edits) + for tool, count := range gate.tools { + row := coverage.Tools[tool] + row.ProbeAnswers, row.ObservedEdits, row.DecodeFailures, row.ClaimsFresh, row.CorrectEdits = count.answers, len(count.edits), count.decodeFailures, count.claimsFresh, len(count.correct) + if row.State == "UNSUPPORTED" { + refused, fallback := len(count.refused), count.fallbackAnswers + row.RefusedEdits, row.FallbackAnswers = &refused, &fallback + } else if row.ObservedEdits > 0 { + row.State = "OBSERVED" + } + coverage.Tools[tool] = row + } + } + coverage.ObservedEdits = &observed + } switch mode { case "gcx": coverage.State, coverage.Reason, coverage.ClaimsFreshObservable = "unavailable", errEditOutputGCXUnavailable.Error(), false case "compact": coverage.Reason, coverage.ClaimsFreshObservable = "compact text has no freshness field; it proves call-addition presence only", false } - if mode == selected { + if mode == selected || selected == "gate" && mode != "gcx" { coverage.State = "selected" } switch { case !coverage.ClaimsFreshObservable: coverage.StaleClaimedFresh = unavailable("answers", "mode cannot carry a freshness claim here") + case selected == "gate" && count.claimsFresh == 0: + coverage.StaleClaimedFresh = unavailable("answers", "no explicit freshness claims from assigned edits in this mode") case count.answers == 0: coverage.StaleClaimedFresh = unavailable("answers", "no probe answers in this mode") default: @@ -72,9 +139,17 @@ func editProbeOutputCoverage(selected string, counts map[string]editProbeModeCou // A CLI probe sends the same JSON facade request through its own process, so // its answers are decoded and counted as CLI stdout, never as MCP JSON. -func (r *editProbeRuntime) probeOutput(oracle editProbeOracle) string { - if r.plan.Mode == "cli" { - return "cli" +func (r *editProbeRuntime) stepProbeMode(step editProbeStep) string { + if step.ProbeMode != "" { + return step.ProbeMode + } + return r.plan.Mode +} + +func (r *editProbeRuntime) probeOutput(oracle editProbeOracle, step editProbeStep) string { + mode := r.stepProbeMode(step) + if mode == "cli" || mode == "facade" { + return mode } return editProbeRequestOutput(oracle.call) } @@ -104,6 +179,63 @@ func (r *editProbeRuntime) countMode(mode string, answered, decoded, fresh, stal r.modeCounts[mode] = count } +// Gate witnesses belong to the record's assigned surface. A real JSON +// fallback remains counted as JSON traffic, but cannot lend edit coverage or +// freshness claims to either another record's JSON cell or its compact cell. +func (r *editProbeRuntime) countGateMode(step editProbeStep, mode, tool string, answered, decoded, fresh, stale, precommit, correct, postACK bool) { + if !r.plan.GateModes || !answered { + return + } + fallback := step.ProbeMode == "compact" && mode == "json" && !editProbeCompactOperations[tool] + if mode != step.ProbeMode && !fallback { + return + } + r.mu.Lock() + defer r.mu.Unlock() + count := r.modeCounts[step.ProbeMode] + if count.gate == nil { + count.gate = &editProbeGateCounts{edits: map[int]bool{}, tools: map[string]*editProbeGateToolCounts{}} + } + gate := count.gate + if gate.tools[tool] == nil { + gate.tools[tool] = &editProbeGateToolCounts{edits: map[int]bool{}, correct: map[int]bool{}, refused: map[int]bool{}} + } + toolCount := gate.tools[tool] + if fallback { + toolCount.fallbackAnswers++ + if decoded && postACK { + toolCount.refused[step.Ordinal] = true + } + if !decoded { + toolCount.decodeFailures++ + gate.decodeFailures++ + } + } else { + gate.answers++ + toolCount.answers++ + if decoded && postACK { + gate.edits[step.Ordinal], toolCount.edits[step.Ordinal] = true, true + if correct { + toolCount.correct[step.Ordinal] = true + } + } else if !decoded { + gate.decodeFailures++ + toolCount.decodeFailures++ + } + if fresh && postACK { + toolCount.claimsFresh++ + gate.claimsFresh++ + } + if stale { + gate.stale++ + } + if precommit { + gate.precommit++ + } + } + r.modeCounts[step.ProbeMode] = count +} + func editProbeRequestOutput(req request) string { output, exists := req.Arguments["output"] if !exists { diff --git a/bench/load/edit_probe_plan.go b/bench/load/edit_probe_plan.go index ea9e4a8e4..4d1e5fa72 100644 --- a/bench/load/edit_probe_plan.go +++ b/bench/load/edit_probe_plan.go @@ -24,6 +24,7 @@ const editProbeMaxEditors = 32 const editProbeFixedEditorModel = "fixed_editor_per_ordinal_plus_one_separate_prober_per_edit" type editProbeStep struct { + ProbeMode string `json:"probe_mode,omitempty"` Ordinal int `json:"ordinal"` ActivateNS int64 `json:"activate_ns"` DueNS int64 `json:"due_ns"` @@ -60,6 +61,7 @@ type editProbeRoute struct { } type editProbePlan struct { + GateModes bool `json:"gate_modes,omitempty"` Version int `json:"version"` Arrival string `json:"arrival"` MeanNS int64 `json:"mean_interarrival_ns"` @@ -134,9 +136,22 @@ func planEditProbe(c Corpus, o runOptions) (editProbePlan, error) { mean = editProbeMean } p, err := makeEditProbeTrafficPlanWithMean(c, o.Seed, o.EditCount, o.Editors, mean) - if err != nil || o.Agents == 0 { + if err != nil { return p, err } + if o.EditGateModes { + if p.Count != 100 || p.MeanNS != int64(editProbeMean) || p.Mode != "json" || p.Kind != "add_intrafile_call" || p.Tool != "edit.file" { + return editProbePlan{}, errors.New("five-mode gate requires 100 call-addition edits with unchanged JSON edit.file ACKs") + } + p.GateModes = true + modes := []string{"json", "toon", "compact", "facade", "cli"} + for i := range p.Steps { + p.Steps[i].ProbeMode = modes[i%len(modes)] + } + } + if o.Agents == 0 { + return p, nil + } if c.EditSender == editProbeRestart { return editProbePlan{}, errors.New("the restart cell has no pure readers") } diff --git a/bench/load/edit_probe_run.go b/bench/load/edit_probe_run.go index eaa1b3232..3d0af8fbe 100644 --- a/bench/load/edit_probe_run.go +++ b/bench/load/edit_probe_run.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "encoding/json" "errors" @@ -29,6 +30,11 @@ type editProbeReadback struct { ObservationOnly bool `json:"observation_only,omitempty"` } type editProbeRecord struct { + AdmittedTargetLines *int `json:"admitted_target_lines,omitempty"` + MutationTiers *editProbeLiveTiers `json:"mutation_tiers,omitempty"` + GateReadback *Metric `json:"t_gate_readback_ms,omitempty"` + ACKReadbackExact *bool `json:"ack_readback_exact,omitempty"` + ProbeMode string `json:"probe_mode,omitempty"` Ordinal int `json:"ordinal"` Editor int `json:"editor,omitempty"` Route string `json:"route,omitempty"` @@ -137,7 +143,7 @@ type editProbeRuntime struct { } func newEditProbeRecord(c Corpus, step editProbeStep) editProbeRecord { - record := editProbeRecord{Ordinal: step.Ordinal, Editor: step.Editor, Route: step.Route, Marker: step.Marker, MutationID: step.MutationID, PlannedDueNS: step.DueNS, Outcome: "phase_censored", Ack: unavailable("ms", "edit not sent"), PhasesMS: editProbePhases(nil, "no receipt status observed"), Readback: unavailable("ms", "edit not acknowledged"), Derived: unavailable("ms", "no receipt status observed"), FirstCorrect: make(map[string]Metric), Accounting: editProbeAccounting{State: "not_observed"}, Errors: []string{"scenario never reached edit dispatch"}} + record := editProbeRecord{ProbeMode: step.ProbeMode, Ordinal: step.Ordinal, Editor: step.Editor, Route: step.Route, Marker: step.Marker, MutationID: step.MutationID, PlannedDueNS: step.DueNS, Outcome: "phase_censored", Ack: unavailable("ms", "edit not sent"), PhasesMS: editProbePhases(nil, "no receipt status observed"), Readback: unavailable("ms", "edit not acknowledged"), Derived: unavailable("ms", "no receipt status observed"), FirstCorrect: make(map[string]Metric), Accounting: editProbeAccounting{State: "not_observed"}, Errors: []string{"scenario never reached edit dispatch"}} for _, oracle := range editProbeOracles(c, step) { record.FirstCorrect[oracle.name] = unavailable("ms", "not observed") } @@ -628,13 +634,14 @@ func (r *editProbeRuntime) observeProbe(s *editProbeState, oracle editProbeOracl if !identity.WriteStarted.IsZero() { sent = identity.WriteStarted } - body, decodeErr := decodeEditProbeOutput(data, r.probeOutput(oracle), oracle.call.name()) + body, decodeErr := decodeEditProbeOutput(data, r.probeOutput(oracle, s.step), oracle.call.name()) absence, exactRefusal := err.(*editProbeExpectedAbsence) expectedAbsence := oracle.call.ExpectedAbsentSymbol != "" && exactRefusal && absence.symbol == repo.Prefix+"/"+oracle.call.ExpectedAbsentSymbol correct := expectedAbsence || err == nil && decodeErr == nil && editProbeCorrect(oracle.name, body, repo.Corpus, s.step, r.calleeFor(repo)) fresh := decodeErr == nil && editProbeClaimsFresh(body) stale, precommit := false, false s.mu.Lock() + postACK := !s.acknowledged.IsZero() && !sent.Before(s.acknowledged) if expectedAbsence { s.record.ExpectedRefusals++ } else if err != nil { @@ -661,8 +668,9 @@ func (r *editProbeRuntime) observeProbe(s *editProbeState, oracle editProbeOracl } } s.mu.Unlock() - r.countMode(r.probeOutput(oracle), err == nil || expectedAbsence, err != nil || decodeErr == nil, fresh, stale, precommit) - return s.emit(map[string]any{"event": "probe", "oracle": oracle.name, "start_ns": editProbeOffset(sent, r.origin), "end_ns": editProbeOffset(ended, r.origin), "correct": correct, "claims_fresh": fresh, "receipt_observed_pending": pending}) + r.countMode(r.probeOutput(oracle, s.step), err == nil || expectedAbsence, err != nil || decodeErr == nil, fresh, stale, precommit) + r.countGateMode(s.step, r.probeOutput(oracle, s.step), oracle.call.name(), err == nil || expectedAbsence, decodeErr == nil, fresh, stale, precommit, correct, postACK) + return s.emit(map[string]any{"event": "probe", "oracle": oracle.name, "requested_mode": r.stepProbeMode(s.step), "output_mode": r.probeOutput(oracle, s.step), "start_ns": editProbeOffset(sent, r.origin), "end_ns": editProbeOffset(ended, r.origin), "correct": correct, "claims_fresh": fresh, "receipt_observed_pending": pending}) } // Activation only starts owned loops and returns. It never waits for a probe @@ -681,7 +689,13 @@ func (r *editProbeRuntime) probe(probeCtx context.Context, cancel context.Cancel cancel() return } - for index, oracle := range editProbeOracles(repo.Corpus, s.step) { + corpus := repo.Corpus + corpus.EditOutput = r.stepProbeMode(s.step) + for index, oracle := range editProbeOracles(corpus, s.step) { + if r.plan.GateModes && r.stepProbeMode(s.step) != "compact" { + oracle.call.Arguments = copyJSON(oracle.call.Arguments) + oracle.call.Arguments["require_fresh"] = true + } s.probes.Go(func() { ticker := time.NewTicker(editProbePeriod) defer ticker.Stop() @@ -691,7 +705,7 @@ func (r *editProbeRuntime) probe(probeCtx context.Context, cancel context.Cancel var data json.RawMessage var identity wireCallIdentity var err error - if r.plan.Mode == "cli" { + if r.stepProbeMode(s.step) == "cli" { data, identity, err = r.performCLI(probeCtx, s, oracle.call, "oracle:"+oracle.name, 10+index) } else { data, identity, err = r.perform(probeCtx, s, s.client, oracle.call, "oracle:"+oracle.name, "probe", 10+index) @@ -730,6 +744,22 @@ func (r *editProbeRuntime) beginEdit(ctx context.Context, step editProbeStep) (* s.record.StartedNS = &ns s.record.StartLatenessNS = &late s.record.Outcome = "in_flight" + if r.plan.GateModes { + initial := r.original + if len(r.p.Repos) > 1 { + initial = nil + if repo, err := r.repoFor(step); err == nil { + initial = r.originals[repo.Path] + } + } + if initial != nil { + lines := bytes.Count(initial, []byte{'\n'}) + if len(initial) > 0 && initial[len(initial)-1] != '\n' { + lines++ + } + s.record.AdmittedTargetLines = &lines + } + } if s.record.ActivatedNS == nil { s.record.Errors = append(s.record.Errors, "missing pre-edit activation") } @@ -823,6 +853,13 @@ func (r *editProbeRuntime) finishEditAfterACK(ctx, callCtx context.Context, s *e s.mu.Unlock() return } + if r.plan.GateModes { + s.mu.Lock() + if exact, ok := body["readback_exact"].(bool); ok { + s.record.ACKReadbackExact = &exact + } + s.mu.Unlock() + } receipt, _ := body["mutation_receipt"].(string) var move *editProbeMoveReferences var batch *editProbeBatchReferences @@ -882,6 +919,9 @@ func (r *editProbeRuntime) finishEditAfterACK(ctx, callCtx context.Context, s *e s.mu.Unlock() s.signalAck() readbackKinds := []string{"file", "source"} + if r.plan.GateModes { + readbackKinds = []string{"source", "file", "summary", "search_visible", "text"} + } if step.BeforeSymbol != "" && !step.Deleted && step.TargetSymbol == "" { readbackKinds = append(readbackKinds, "old_source_absent") } @@ -924,6 +964,10 @@ func (r *editProbeRuntime) finishEditAfterACK(ctx, callCtx context.Context, s *e s.record.Accounting = accounting s.record.PhasesMS = editProbePhases(status, "status exposes no phases_ms") s.record.Derived = editProbeDerived(status) + s.record.MutationTiers = editProbeStatusTiers(status) + if s.record.MutationTiers != nil && s.record.MutationTiers.State == "invalid" { + s.record.Errors = append(s.record.Errors, "invalid live tier observation: "+s.record.MutationTiers.Reason) + } s.record.GraphTerminal = status["graph_status_terminal"] == true if s.record.GraphTerminal { r.settlePendingLocked(s) @@ -1019,6 +1063,13 @@ func (r *editProbeRuntime) immediateReadbacks(callCtx context.Context, s *editPr } } req := request{Tool: "read", Operation: kind, Arguments: map[string]any{"operation": kind, "target": target}} + if kind == "search_visible" || kind == "text" { + operation := "symbols" + if kind == "text" { + operation = "text" + } + req = request{Tool: "search", Operation: operation, Arguments: map[string]any{"operation": operation, "query": step.Marker}} + } if kind == "source" && step.TargetSymbol != "" { req.Arguments["options"] = map[string]any{"context_lines": 0} } @@ -1054,6 +1105,13 @@ func (r *editProbeRuntime) immediateReadbacks(callCtx context.Context, s *editPr } back.ObservationOnly = observeOnly[kind] s.mu.Lock() + if r.plan.GateModes && index == 0 { + metric := unavailable("ms", "first same-editor symbol-source readback endpoint missing or incorrect") + if back.Correct && !identity.Finished.IsZero() && !s.started.IsZero() && !identity.Finished.Before(s.started) { + metric = measured(float64(identity.Finished.Sub(s.started))/float64(time.Millisecond), "ms") + } + s.record.GateReadback = &metric + } s.record.Readbacks = append(s.record.Readbacks, back) if !back.Correct && !back.ObservationOnly { s.record.Errors = append(s.record.Errors, "immediate "+kind+" readback incorrect") @@ -1147,7 +1205,11 @@ func (r *editProbeRuntime) finish() error { r.phase.EditProbe.Summary = summarizeEditProbe(r.phase.EditProbe.Records, r.plan.Seed) r.mu.Lock() r.phase.EditProbe.Reads = summarizeEditProbeReads(r.reads, r.plan.Seed) - r.phase.EditProbe.OutputModes = editProbeOutputCoverage(r.plan.Mode, r.modeCounts) + selected := r.plan.Mode + if r.plan.GateModes { + selected = "gate" + } + r.phase.EditProbe.OutputModes = editProbeOutputCoverage(selected, r.modeCounts) r.mu.Unlock() return errors.Join(failures...) } diff --git a/bench/load/foreign_impact.go b/bench/load/foreign_impact.go new file mode 100644 index 000000000..0fc33beb4 --- /dev/null +++ b/bench/load/foreign_impact.go @@ -0,0 +1,259 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "math" + "path/filepath" + "slices" + "time" +) + +const foreignImpactDuration = 10 * time.Minute + +// The driver must supply an independently evidenced offered-edit window. +// Timestamp membership here does not certify its traffic or the 600s gate. +type foreignImpactWindow struct { + ID string `json:"id"` + EditedRepo string `json:"edited_repo"` + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +type foreignImpactSample struct { + WindowID string `json:"window_id"` + TargetRepo string `json:"target_repo"` + EditedRepo string `json:"edited_repo"` + TargetSymbol string `json:"target_symbol"` + RPCID int64 `json:"rpc_id"` + QueryCWD string `json:"cwd,omitempty"` + Started time.Time `json:"started"` + Finished time.Time `json:"finished"` + WithinWindow bool `json:"within_window"` + Latency Metric `json:"latency_ms"` + GateWait Metric `json:"gate_wait_ms"` + ResolveMutexWait Metric `json:"resolve_mutex_wait_ms"` + Refused bool `json:"refused"` + Error string `json:"error,omitempty"` + ErrorCode *int `json:"error_code,omitempty"` + ErrorData map[string]any `json:"error_data,omitempty"` + Raw json.RawMessage `json:"raw"` +} + +// observeForeignImpact uses a separate target-owned session and sends exactly +// one public impact RPC. It never retries or hides refusals behind a success. +func observeForeignImpact(ctx context.Context, client *socketClient, target, edited privateRepo, window foreignImpactWindow) foreignImpactSample { + symbol := target.Prefix + "/" + target.Corpus.Symbol + invalid := func(message string) foreignImpactSample { + row := foreignImpactObservation(window, target.Corpus.Label, edited.Corpus.Label, symbol, nil, wireCallIdentity{}, errors.New(message)) + if client != nil { + row.QueryCWD = client.cwd + } + return row + } + if client == nil || client.cwd == "" || target.Path == "" || filepath.Clean(client.cwd) != filepath.Clean(target.Path) { + return invalid("foreign impact requires its target checkout's own query session") + } + if target.Prefix == "" || target.Corpus.Symbol == "" || target.Corpus.Label == "" || edited.Corpus.Label == "" || edited.Path == "" || target.Corpus.Label == edited.Corpus.Label || filepath.Clean(target.Path) == filepath.Clean(edited.Path) { + return invalid("foreign impact requires distinct target and edited repositories") + } + if window.ID == "" || window.EditedRepo != edited.Corpus.Label || window.Start.IsZero() || !window.End.After(window.Start) { + return invalid("foreign impact has no valid owned edit window") + } + args := map[string]any{"operation": "impact", "target": map[string]any{"symbol": symbol}, "output": map[string]any{"format": "json", "max_bytes": 0}} + raw, identity, err := client.callWithIdentity(ctx, "tools/call", map[string]any{"name": "change", "arguments": args}) + row := foreignImpactObservation(window, target.Corpus.Label, edited.Corpus.Label, symbol, raw, identity, err) + row.QueryCWD = client.cwd + return row +} + +func foreignImpactObservation(window foreignImpactWindow, targetRepo, editedRepo, symbol string, raw json.RawMessage, identity wireCallIdentity, callErr error) foreignImpactSample { + row := foreignImpactSample{WindowID: window.ID, TargetRepo: targetRepo, EditedRepo: editedRepo, TargetSymbol: symbol, RPCID: identity.ID, Started: identity.Started, Finished: identity.Finished, Raw: append(json.RawMessage(nil), raw...), + Latency: unavailable("ms", "RPC timestamps unavailable"), GateWait: unavailable("ms", "ABSENT: no impact topology-gate wait producer"), ResolveMutexWait: unavailable("ms", "ABSENT: no impact resolve-mutex wait producer")} + if identity.ID != 0 && !identity.Started.IsZero() && !identity.Finished.Before(identity.Started) { + row.Latency = measured(float64(identity.Finished.Sub(identity.Started))/float64(time.Millisecond), "ms") + row.WithinWindow = window.ID != "" && window.EditedRepo == editedRepo && targetRepo != "" && targetRepo != editedRepo && !window.Start.IsZero() && window.End.After(window.Start) && !identity.Started.Before(window.Start) && !identity.Finished.After(window.End) + } + var remote *rpcError + row.Refused = errors.As(callErr, &remote) + if remote != nil { + code := remote.Code + row.ErrorCode = &code + row.ErrorData = copyRPCErrorData(remote.Data) + } + if len(raw) > 0 { + var envelope struct { + IsError bool `json:"isError"` + Meta map[string]any `json:"_meta"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + callErr = errors.Join(callErr, err) + } else { + row.GateWait = foreignImpactWait(envelope.Meta, "gate_wait") + row.ResolveMutexWait = foreignImpactWait(envelope.Meta, "resolve_mutex_wait") + if envelope.IsError { + row.Refused = true + callErr = errors.Join(callErr, checkToolResult(raw)) + } + } + } else if callErr == nil { + callErr = errors.New("impact RPC returned no raw response") + } + if callErr != nil { + row.Error = callErr.Error() + } + return row +} + +type foreignImpactPairSummary struct { + TargetRepo string `json:"target_repo"` + EditedRepo string `json:"edited_repo"` + Summary foreignImpactSummary `json:"summary"` +} + +type foreignImpactSummary struct { + Offered int `json:"offered"` + Pairs []foreignImpactPairSummary `json:"pairs,omitempty"` + WithinWindow int `json:"within_window"` + OutsideWindow int `json:"outside_window"` + Unavailable int `json:"unavailable"` + Refusals int `json:"refusals"` + Errors int `json:"errors"` + Latency Distribution `json:"latency_ms"` + GateWait Distribution `json:"gate_wait_ms"` + ResolveMutexWait Distribution `json:"resolve_mutex_wait_ms"` + GateWaitUnavailable int `json:"gate_wait_unavailable"` + ResolveMutexWaitUnavailable int `json:"resolve_mutex_wait_unavailable"` +} + +// All offers remain counted. The window distribution includes failed and +// refused RPCs; unavailable timestamps remain explicit rather than zero. +func summarizeForeignImpact(rows []foreignImpactSample) foreignImpactSummary { + out := summarizeForeignImpactRows(rows) + grouped := make(map[string][]foreignImpactSample) + for _, row := range rows { + key := row.TargetRepo + "\x00" + row.EditedRepo + grouped[key] = append(grouped[key], row) + } + keys := make([]string, 0, len(grouped)) + for key := range grouped { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + group := grouped[key] + out.Pairs = append(out.Pairs, foreignImpactPairSummary{TargetRepo: group[0].TargetRepo, EditedRepo: group[0].EditedRepo, Summary: summarizeForeignImpactRows(group)}) + } + return out +} + +func summarizeForeignImpactRows(rows []foreignImpactSample) foreignImpactSummary { + out := foreignImpactSummary{Offered: len(rows)} + var values, gateWaits, resolveWaits []float64 + for _, row := range rows { + if row.Error != "" { + out.Errors++ + } + if row.Refused { + out.Refusals++ + } + if row.Latency.Value == nil { + out.Unavailable++ + } + if row.WithinWindow { + out.WithinWindow++ + if row.Latency.Value != nil { + values = append(values, *row.Latency.Value) + } + if row.GateWait.Value != nil { + gateWaits = append(gateWaits, *row.GateWait.Value) + } else { + out.GateWaitUnavailable++ + } + if row.ResolveMutexWait.Value != nil { + resolveWaits = append(resolveWaits, *row.ResolveMutexWait.Value) + } else { + out.ResolveMutexWaitUnavailable++ + } + } else { + out.OutsideWindow++ + } + } + out.Latency = distribution(values, "ms") + out.GateWait = distribution(gateWaits, "ms") + out.ResolveMutexWait = distribution(resolveWaits, "ms") + return out +} + +// Only a complete producer observation can supply a wait sample. Detached, +// omitted, malformed, and interrupted observations never become measured zero. +func foreignImpactWait(meta map[string]any, name string) Metric { + waits, _ := meta["reach_coordination"].(map[string]any) + phase, _ := waits[name].(map[string]any) + if phase == nil { + return unavailable("ms", "ABSENT: impact coordination producer omits "+name) + } + state, _ := phase["state"].(string) + if state != "observed" { + return unavailable("ms", "producer wait state "+state) + } + attempted, attemptedOK := phase["attempted"].(float64) + acquired, acquiredOK := phase["acquired"].(float64) + ms, msOK := phase["total_ms"].(float64) + if !attemptedOK || !acquiredOK || attempted < 1 || attempted != math.Trunc(attempted) || acquired != attempted || !msOK || math.IsNaN(ms) || math.IsInf(ms, 0) || ms < 0 { + return unavailable("ms", "invalid or incomplete producer wait observation") + } + return measured(ms, "ms") +} + +type foreignImpactTarget struct { + Client *socketClient + Repo privateRepo +} + +// runForeignImpactStream reuses the existing relation-prober cadence. It is a +// single query stream, never a new editor schedule. emit must retain every row; +// a persistence failure terminates evidence instead of hiding observations. +func runForeignImpactStream(ctx context.Context, targets []foreignImpactTarget, edited privateRepo, window foreignImpactWindow, emit func(foreignImpactSample) error) (foreignImpactSummary, error) { + if len(targets) == 0 || emit == nil || window.Start.IsZero() || !window.End.After(window.Start) || window.End.Sub(window.Start) > foreignImpactDuration { + return summarizeForeignImpact(nil), errors.New("foreign impact requires a bounded window and raw evidence sink") + } + if err := waitContext(ctx, time.Until(window.Start)); err != nil { + return summarizeForeignImpact(nil), err + } + probeCtx, cancel := context.WithDeadline(ctx, window.End) + defer cancel() + ticker := time.NewTicker(editProbePeriod) + defer ticker.Stop() + // Only scalar samples are retained here; raw responses belong to emit. + var rows []foreignImpactSample + for time.Now().Before(window.End) && ctx.Err() == nil { + target := targets[len(rows)%len(targets)] + row := observeForeignImpact(probeCtx, target.Client, target.Repo, edited, window) + retained := row + row.Raw = nil + row.ErrorData = nil + row.ErrorCode = nil + if row.Error != "" { + row.Error = "observed product error; raw sink carries details" + } + row.Latency.Reason, row.GateWait.Reason, row.ResolveMutexWait.Reason = "", "", "" + rows = append(rows, row) + if err := emit(retained); err != nil { + return summarizeForeignImpact(rows), err + } + if row.RPCID == 0 { + return summarizeForeignImpact(rows), errors.New(row.Error) + } + select { + case <-probeCtx.Done(): + if ctx.Err() != nil { + return summarizeForeignImpact(rows), ctx.Err() + } + return summarizeForeignImpact(rows), nil + case <-ticker.C: + } + } + return summarizeForeignImpact(rows), ctx.Err() +} diff --git a/bench/load/foreign_impact_command.go b/bench/load/foreign_impact_command.go new file mode 100644 index 000000000..2b482f627 --- /dev/null +++ b/bench/load/foreign_impact_command.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "time" +) + +func parseForeignImpact(args []string) (runOptions, int, error) { + var o runOptions + f := flag.NewFlagSet("load foreign-impact", flag.ContinueOnError) + bindRunFlags(f, &o) + edited := f.Int("edited-repo", 0, "zero-based edited repository in the pinned six-root manifest") + if err := f.Parse(args); err != nil { + return o, *edited, err + } + if f.NArg() != 0 { + return o, *edited, errors.New("unexpected foreign-impact positional arguments") + } + if err := validateRunOptions(o); err != nil { + return o, *edited, err + } + if o.Scenario != "many" || o.Agents != 6 || o.Editors != 1 || o.Worktrees != 0 || o.EditRepos != 1 || o.EditCount != 100 || o.Seed != 42 || o.Profile != "agent" || o.CPUProfile != 0 || o.PRSmokeDrain != 0 || o.Duration != foreignImpactDuration || o.Deadline != 120*time.Second || o.WarmTimeout != 30*time.Minute || o.StopTimeout != 30*time.Second || *edited < 0 || *edited >= 6 { + return o, *edited, errors.New("foreign-impact requires many/6 sessions/1 editor/0 worktrees, seed42,100 edits, agent profile, duration10m, deadline120s, warm30m, stop30s and edited-repo0..5") + } + return o, *edited, nil +} + +// The separate entry retains the ordinary run lifecycle and its host checks. +// It never lengthens or replaces the main six-editor 100-offer experiment. +func foreignImpactCommand(ctx context.Context, args []string) error { + o, edited, err := parseForeignImpact(args) + if err != nil { + return err + } + corpora, err := loadManifest(o.Manifest) + if err != nil { + return err + } + if len(corpora) != 6 { + return errors.New("foreign-impact requires six distinct pinned repositories") + } + if err := validateOutputRoots(corpora, o.Scratch, o.Output); err != nil { + return err + } + sha, err := binaryRevision(o.Binary, o.GitSHA) + if err != nil { + return err + } + if err := os.MkdirAll(o.Scratch, 0o700); err != nil { + return err + } + host, group, err := inspectHost(o.Host, o.Scratch) + if err != nil { + return err + } + root, err := os.MkdirTemp(o.Scratch, "foreign-impact-report-") + if err != nil { + return err + } + if o.Output == "" { + o.Output = filepath.Join(root, "run.json") + } + if err := validateEditProbeSourceRepos(ctx, corpora, root); err != nil { + return err + } + report, err := newForeignImpactRunReport(o, corpora, edited, sha, host, root) + if err != nil { + return err + } + foreignPath := filepath.Join(root, "foreign-impact-summary.json") + o.pairedDriver = func(ctx context.Context, p *privateRun, clients, _ []*socketClient, phase *Phase) error { + cell, runErr := runForeignImpactWithEdits(ctx, o, p, clients, phase, edited) + data, encodeErr := json.MarshalIndent(cell, "", " ") + if encodeErr == nil { + encodeErr = os.WriteFile(foreignPath, append(data, '\n'), 0o600) + } + return errors.Join(runErr, encodeErr) + } + phase, sweep, artifacts, crashes, version, runErr := runPhase(ctx, o, "many", corpora, group, root) + phase.Name = "foreign-impact" + phase.SessionModel = report.Workload.SessionModel + phase.Operations = report.Workload.Operations + artifacts["foreign_impact_summary"] = foreignPath + addPhase(&report, phase, sweep, artifacts, crashes, version, runErr) + if err := finishReport(&report, 1); err != nil { + return err + } + if err := writeReport(o.Output, report); err != nil { + return err + } + if !report.Complete { + return fmt.Errorf("incomplete foreign-impact run; evidence retained in %s", o.Output) + } + return nil +} + +// The ordinary many lifecycle is an implementation detail. The persisted load +// identity names the actual five foreign readers and complete B edit plan; +// duration is the query window, while Phase retains the full edit/drain time. +func newForeignImpactRunReport(o runOptions, corpora []Corpus, edited int, sha string, host Host, root string) (Report, error) { + if len(corpora) != 6 || edited < 0 || edited >= len(corpora) { + return Report{}, errors.New("foreign report requires six roots and an edited-repository ordinal") + } + report, err := newRunReport(o, corpora, sha, host, root) + if err != nil { + return report, err + } + plan, err := foreignImpactEditPlan(corpora[edited], o.Seed) + if err != nil { + return report, err + } + report.Workload.Scenario = "foreign-impact" + report.Workload.Agents = 5 + report.Workload.EditAgents = 1 + report.Workload.SessionModel = "one_B_editor_and_five_independent_rotating_foreign_queries" + report.Workload.EditProbe = &plan + report.Workload.Operations = editProbeOperations(plan, corpora[edited]) + for i, c := range corpora { + if i == edited { + continue + } + report.Workload.Operations = append(report.Workload.Operations, request{Tool: "change", Operation: "impact", Arguments: map[string]any{"operation": "impact", "target": map[string]any{"symbol": c.Label + "/" + c.Symbol}, "output": map[string]any{"format": "json", "max_bytes": 0}}}) + } + report.RequestPlan, err = hashJSON(report.Workload) + return report, err +} diff --git a/bench/load/foreign_impact_driver.go b/bench/load/foreign_impact_driver.go new file mode 100644 index 000000000..763150489 --- /dev/null +++ b/bench/load/foreign_impact_driver.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +const foreignImpactRawLimit = int64(1 << 30) + +type foreignImpactEvidenceWriter struct { + w io.Writer + bytes int64 + limit int64 +} + +func (w *foreignImpactEvidenceWriter) emit(row foreignImpactSample) error { + data, err := json.Marshal(row) + if err != nil { + return err + } + data = append(data, '\n') + if w.limit <= 0 || int64(len(data)) > w.limit-w.bytes { + return errors.New("foreign impact raw evidence limit exhausted; execution incomplete") + } + n, err := w.w.Write(data) + w.bytes += int64(n) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + return err +} + +type foreignImpactRunReport struct { + EditedRepo string `json:"edited_repo"` + TargetRepos []string `json:"target_repos"` + PlanHash string `json:"edit_plan_sha256"` + EditOffers int `json:"edit_offers"` + EditMeanNS int64 `json:"edit_mean_ns"` + FirstDueNS int64 `json:"first_edit_due_ns"` + LastDueNS int64 `json:"last_edit_due_ns"` + FirstEditWrite time.Time `json:"first_edit_write"` + Window foreignImpactWindow `json:"window"` + ProbePeriodNS int64 `json:"probe_period_ns"` + ProbeStarted time.Time `json:"probe_started"` + ProbeFinished time.Time `json:"probe_finished"` + Summary foreignImpactSummary `json:"summary"` + RawFile string `json:"raw_file"` + RawBytes int64 `json:"raw_bytes"` + RawLimit int64 `json:"raw_limit"` + Complete bool `json:"complete"` +} + +// The foreign cell keeps the accepted one-editor Poisson schedule. Unlike the +// six-editor 100-global-offer cell, this stream must span the full ten minutes. +func foreignImpactEditPlan(c Corpus, seed int64) (editProbePlan, error) { + plan, err := planEditProbe(c, runOptions{Seed: seed, EditCount: 100, Editors: 1}) + if err != nil { + return plan, err + } + if len(plan.Steps) != 100 || plan.MeanNS != int64(editProbeMean) || plan.Steps[len(plan.Steps)-1].DueNS-plan.Steps[0].DueNS < int64(foreignImpactDuration) { + return plan, errors.New("fixed foreign edit plan does not cover ten active minutes; never fill the remainder with idle queries") + } + return plan, nil +} + +func foreignImpactQueryTargets(repos []privateRepo, clients []*socketClient, edited int) ([]foreignImpactTarget, error) { + if len(repos) != 6 || edited < 0 || edited >= len(repos) { + return nil, errors.New("foreign impact requires the six pinned repositories and one selected editor") + } + var targets []foreignImpactTarget + for i, repo := range repos { + if i == edited { + continue + } + var owner *socketClient + for _, client := range clients { + if client != nil && filepath.Clean(client.cwd) == filepath.Clean(repo.Path) { + if owner != nil { + return nil, errors.New("ambiguous target query session") + } + owner = client + } + } + if owner == nil { + return nil, fmt.Errorf("no own query session for %s", repo.Corpus.Label) + } + targets = append(targets, foreignImpactTarget{Client: owner, Repo: repo}) + } + return targets, nil +} + +// runForeignImpactWithEdits is called from the existing owned runPhase driver +// hook after all six roots pass readiness. It creates no process, store, source +// clone, or new traffic scheduler. The normal lifecycle still owns shutdown. +func runForeignImpactWithEdits(ctx context.Context, o runOptions, p *privateRun, clients []*socketClient, phase *Phase, editedOrdinal int) (report foreignImpactRunReport, resultErr error) { + defer func() { + if resultErr != nil { + report.Complete = false + } + }() + targets, err := foreignImpactQueryTargets(p.Repos, clients, editedOrdinal) + if err != nil { + return report, err + } + edited := p.Repos[editedOrdinal] + plan, err := foreignImpactEditPlan(edited.Corpus, o.Seed) + if err != nil { + return report, err + } + if phase.editProbeRuntime != nil { + return report, errors.New("foreign driver cannot replace an admitted edit plan") + } + report.EditedRepo = edited.Corpus.Label + report.EditOffers = len(plan.Steps) + report.EditMeanNS = plan.MeanNS + report.FirstDueNS = plan.Steps[0].DueNS + report.LastDueNS = plan.Steps[len(plan.Steps)-1].DueNS + report.ProbePeriodNS = int64(editProbePeriod) + report.PlanHash, _ = hashJSON(plan) + for _, target := range targets { + report.TargetRepos = append(report.TargetRepos, target.Repo.Corpus.Label) + } + editor, err := openMCP(ctx, p.Socket, edited.Path) + if err != nil { + return report, err + } + defer editor.Close() + // All roots stay registered in the daemon. Only B owns offered edits. + one := &privateRun{Root: p.Root, SocketDir: p.SocketDir, Socket: p.Socket, Store: p.Store, Config: p.Config, Env: p.Env, Repos: []privateRepo{edited}, Binary: p.Binary} + options := o + options.Agents = 0 + options.Editors = 1 + options.EditRepos = 1 + options.Worktrees = 0 + options.EditCount = 100 + options.pairedDriver = nil + runtime, probers, err := prepareEditProbe(ctx, options, one, nil, []*socketClient{editor}, phase) + defer func() { + for _, client := range probers { + _ = client.Close() + } + if runtime != nil { + resultErr = errors.Join(resultErr, runtime.close()) + } + phase.editProbeRuntime = nil + }() + if err != nil { + return report, err + } + phase.SessionModel = "one_B_editor_and_five_independent_rotating_foreign_queries" + phase.Metrics["mcp_sessions"] = measured(float64(len(clients)+len(probers)+1), "sessions") + rawPath := filepath.Join(p.Root, "foreign-impact.jsonl") + raw, err := os.OpenFile(rawPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return report, err + } + defer func() { resultErr = errors.Join(resultErr, raw.Close()) }() + report.RawFile = rawPath + report.RawLimit = foreignImpactRawLimit + evidence := foreignImpactEvidenceWriter{w: raw, limit: foreignImpactRawLimit} + defer func() { report.RawBytes = evidence.bytes }() + budget, err := plan.budget(o.Deadline, 1) + if err != nil { + return report, err + } + measuredCtx, cancel := context.WithTimeout(ctx, budget) + defer cancel() + captureReceipt(ctx, one, editor, phase) + phase.EditProbe.StatsBefore, err = runtime.stats(ctx) + if err != nil { + return report, err + } + phase.Started = time.Now() + runtime.origin = phase.Started + done := make(chan error, 1) + go func() { + scheduleErr := runEditProbeArrivals(measuredCtx, runtime.plan, runtime.origin, runtime.activate, runtime.doEdit, waitContext) + done <- errors.Join(scheduleErr, runtime.finish()) + }() + // The first actual edit write is already retained by the existing RPC + // observer. Wait for that evidence, rather than assuming its planned due time. + var editErr error + editsJoined := false + ticker := time.NewTicker(editProbePeriod) +awaitWrite: + for report.FirstEditWrite.IsZero() && measuredCtx.Err() == nil { + first := runtime.states[0] + first.mu.Lock() + report.FirstEditWrite = first.editWrite + first.mu.Unlock() + if !report.FirstEditWrite.IsZero() { + break + } + select { + case <-ticker.C: + case <-measuredCtx.Done(): + case editErr = <-done: + editsJoined = true + break awaitWrite + } + } + ticker.Stop() + if report.FirstEditWrite.IsZero() { + cancel() + resultErr = errors.New("no actual B edit write was observed") + } else { + report.Window = foreignImpactWindow{ID: "foreign-" + edited.Corpus.Label, EditedRepo: edited.Corpus.Label, Start: time.Now()} + report.Window.End = report.Window.Start.Add(foreignImpactDuration) + if runtime.origin.Add(time.Duration(report.LastDueNS)).Before(report.Window.End) { + cancel() + resultErr = errors.New("actual edit start was too late to cover the full foreign window") + } else { + report.ProbeStarted = time.Now() + report.Summary, err = runForeignImpactStream(measuredCtx, targets, edited, report.Window, evidence.emit) + report.ProbeFinished = time.Now() + resultErr = errors.Join(resultErr, err) + if err != nil { + cancel() + } + } + } + if !editsJoined { + editErr = <-done + } + resultErr = errors.Join(resultErr, editErr) + phase.EditProbe.StatsAfter, err = runtime.stats(ctx) + resultErr = errors.Join(resultErr, err) + if ctx.Err() == nil { + restoreCtx, stop := context.WithTimeout(ctx, o.Deadline) + resultErr = errors.Join(resultErr, runtime.restore(restoreCtx)) + stop() + } else { + resultErr = errors.Join(resultErr, errors.New("foreign-cell original restore unavailable after parent cancellation")) + } + report.Complete = resultErr == nil && !report.ProbeFinished.Before(report.Window.End) && report.Summary.Offered > 0 + phase.Metrics["foreign_impact_p95_ms"] = report.Summary.Latency.P95 + phase.Metrics["foreign_impact_refusals"] = measured(float64(report.Summary.Refusals), "calls") + phase.Metrics["foreign_impact_errors"] = measured(float64(report.Summary.Errors), "calls") + return report, resultErr +} diff --git a/bench/load/foreign_impact_driver_test.go b/bench/load/foreign_impact_driver_test.go new file mode 100644 index 000000000..52e768946 --- /dev/null +++ b/bench/load/foreign_impact_driver_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "encoding/json" + "path/filepath" + "reflect" + "testing" + "time" +) + +func TestForeignImpactEditPlanKeepsAcceptedOneEditorTrafficAndCoversWindow(t *testing.T) { + c := editProbeCorpus() + original, err := makeEditProbePlan(c, 42, 100) + if err != nil { + t.Fatal(err) + } + plan, err := foreignImpactEditPlan(c, 42) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(plan, original) { + t.Fatal("foreign window changed the fixed editor's seeded requests/arrivals") + } + if len(plan.Steps) != 100 || plan.MeanNS != int64(15*time.Second) || plan.Steps[99].DueNS-plan.Steps[0].DueNS < int64(10*time.Minute) { + t.Fatalf("fixed edit traffic cannot support required active window: %+v", plan) + } + six, err := makeEditProbeTrafficPlan(c, 42, 100, 6) + if err != nil { + t.Fatal(err) + } + if six.Steps[99].DueNS-six.Steps[0].DueNS >= int64(foreignImpactDuration) { + t.Fatal("control no longer demonstrates why the short six-editor stream cannot be padded with idle probes") + } +} + +func TestForeignImpactQueryTargetsRequireEachForeignCheckoutOwnSession(t *testing.T) { + root := t.TempDir() + var repos []privateRepo + var clients []*socketClient + for _, name := range []string{"one", "two", "three", "four", "five", "six"} { + repo := privateRepo{Path: filepath.Join(root, name), Prefix: name, Corpus: Corpus{Label: name}} + repos = append(repos, repo) + clients = append(clients, &socketClient{cwd: repo.Path}) + } + targets, err := foreignImpactQueryTargets(repos, clients, 2) + if err != nil || len(targets) != 5 { + t.Fatalf("six-root target selection failed: %v %+v", err, targets) + } + for _, target := range targets { + if target.Repo.Corpus.Label == "three" || target.Client.cwd != target.Repo.Path { + t.Fatal("edited/foreign checkout was substituted") + } + } + if _, err := foreignImpactQueryTargets(repos, append(clients, clients[0]), 2); err == nil { + t.Fatal("ambiguous duplicate query session accepted") + } + if _, err := foreignImpactQueryTargets(repos, clients[:5], 2); err == nil { + t.Fatal("missing target session fell back to primary") + } +} + +func TestForeignImpactCommandRejectsChangedTrafficAndBudgets(t *testing.T) { + args := []string{"--binary", "/owned/product", "--manifest", "/owned/manifest", "--scratch", "/owned/runs", "--scenario", "many", "--agents", "6", "--edit-agents", "1", "--worktrees", "0", "--seed", "42", "--duration", "10m"} + if _, _, err := parseForeignImpact(args); err != nil { + t.Fatal(err) + } + for _, changed := range [][]string{{"--seed", "43"}, {"--edit-count", "1"}, {"--duration", "9m"}, {"--agents", "7"}, {"--deadline", "119s"}, {"--edited-repo", "6"}} { + if _, _, err := parseForeignImpact(append(append([]string(nil), args...), changed...)); err == nil { + t.Fatalf("accepted changed foreign cell %v", changed) + } + } +} + +func TestForeignImpactEvidenceLimitRetainsPriorRowsAndStopsAtBoundary(t *testing.T) { + row := foreignImpactSample{TargetRepo: "A", EditedRepo: "B", RPCID: 9} + data, err := json.Marshal(row) + if err != nil { + t.Fatal(err) + } + var dst bytes.Buffer + writer := foreignImpactEvidenceWriter{w: &dst, limit: int64(len(data) + 1)} + if err := writer.emit(row); err != nil { + t.Fatal(err) + } + before := append([]byte(nil), dst.Bytes()...) + if err := writer.emit(row); err == nil { + t.Fatal("exhausted raw evidence storage silently accepted another offer") + } + if !bytes.Equal(before, dst.Bytes()) || writer.bytes != int64(len(before)) { + t.Fatal("quota failure changed previously retained evidence") + } +} + +func TestForeignImpactRunReportDescribesActualRolesAndPreservesEditPlan(t *testing.T) { + corpora := editProbeRoutingCorpora() + o := runOptions{Scenario: "many", Agents: 6, Editors: 1, Seed: 42, Duration: foreignImpactDuration, Deadline: 120 * time.Second, WarmTimeout: 30 * time.Minute, StopTimeout: 30 * time.Second, Profile: "agent", EditCount: 100} + report, err := newForeignImpactRunReport(o, corpora, 2, corpora[0].Commit, Host{}, "owned-report") + if err != nil { + t.Fatal(err) + } + if report.Workload.Scenario != "foreign-impact" || report.Workload.Agents != 5 || report.Workload.EditAgents != 1 || report.Workload.SessionModel != "one_B_editor_and_five_independent_rotating_foreign_queries" { + t.Fatalf("foreign workload mislabeled as ordinary read-only many cell: %+v", report.Workload) + } + if report.Workload.EditProbe == nil || report.Workload.EditProbe.Count != 100 || max(1, report.Workload.EditProbe.Editors) != 1 || report.Workload.EditProbe.Repo != corpora[2].Label || report.Workload.DurationSec != 600 { + t.Fatal("active foreign window or full B edit schedule lost") + } + plan, err := foreignImpactEditPlan(corpora[2], 42) + if err != nil || !reflect.DeepEqual(*report.Workload.EditProbe, plan) { + t.Fatal("outer report differs from actual seeded B editor plan") + } + if len(report.Workload.Operations) < 5 || report.Workload.Operations[0].name() != "edit.file" { + t.Fatal("outer report omitted offered edits") + } + for i, op := range report.Workload.Operations[len(report.Workload.Operations)-5:] { + foreign := i + if foreign >= 2 { + foreign++ + } + target := op.Arguments["target"].(map[string]any)["symbol"] + if op.name() != "change.impact" || target != corpora[foreign].Label+"/"+corpora[foreign].Symbol { + t.Fatal("foreign target route lost or replaced with B") + } + } + before := report.RequestPlan + for i := range corpora { + corpora[i].Source = filepath.Join("/other-owned-copy", corpora[i].Label) + } + moved, err := newForeignImpactRunReport(o, corpora, 2, corpora[0].Commit, Host{}, "other-report") + if err != nil || moved.RequestPlan != before { + t.Fatal("private clone paths changed the actual foreign workload hash") + } + differentB, err := newForeignImpactRunReport(o, corpora, 1, corpora[0].Commit, Host{}, "other-report") + if err != nil || differentB.RequestPlan == before { + t.Fatal("foreign workload hash omitted edited-repository routing") + } +} diff --git a/bench/load/foreign_impact_test.go b/bench/load/foreign_impact_test.go new file mode 100644 index 000000000..c7cd96ac1 --- /dev/null +++ b/bench/load/foreign_impact_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "testing" + "time" +) + +func TestForeignImpactRetainsRefusalAndOwnedRPC(t *testing.T) { + root := t.TempDir() + target := privateRepo{Path: filepath.Join(root, "target"), Prefix: "target", Corpus: Corpus{Label: "target", Symbol: "file.go::Target"}} + edited := privateRepo{Path: filepath.Join(root, "edited"), Prefix: "edited", Corpus: Corpus{Label: "edited"}} + socket := startStub(t, func(f stubFrame) (any, *rpcError) { + if f.Method != "tools/call" { + return map[string]any{}, nil + } + args, _ := f.Params["arguments"].(map[string]any) + got, _ := args["target"].(map[string]any) + if f.Cwd != target.Path || f.Params["name"] != "change" || args["operation"] != "impact" || got["symbol"] != "target/file.go::Target" { + t.Errorf("foreign impact dispatched to wrong route: %+v", f) + } + return map[string]any{"isError": true, "content": []any{map[string]any{"type": "text", "text": "graph freshness unavailable: relations pending"}}}, nil + }) + client, err := openMCP(t.Context(), socket, target.Path) + if err != nil { + t.Fatal(err) + } + defer client.Close() + window := foreignImpactWindow{ID: "active-edits", EditedRepo: "edited", Start: time.Now().Add(-time.Second), End: time.Now().Add(time.Minute)} + row := observeForeignImpact(t.Context(), client, target, edited, window) + if !row.WithinWindow || !row.Refused || row.Error == "" || row.RPCID == 0 || len(row.Raw) == 0 || row.Latency.Value == nil { + t.Fatalf("refusal or exact RPC evidence was lost: %+v", row) + } + if row.TargetRepo != "target" || row.EditedRepo != "edited" || row.TargetSymbol != "target/file.go::Target" { + t.Fatalf("lost repository attribution: %+v", row) + } + if row.GateWait.Value != nil || row.ResolveMutexWait.Value != nil { + t.Fatalf("missing daemon waits were invented: %+v", row) + } + summary := summarizeForeignImpact([]foreignImpactSample{row}) + if summary.WithinWindow != 1 || summary.Refusals != 1 || summary.Errors != 1 || summary.Latency.Count != 1 { + t.Fatalf("refusal was excluded from denominator: %+v", summary) + } +} + +func TestForeignImpactRejectsBorrowedSessionAndSameRepository(t *testing.T) { + root := t.TempDir() + target := privateRepo{Path: filepath.Join(root, "target"), Prefix: "target", Corpus: Corpus{Label: "target", Symbol: "a.go::Target"}} + edited := privateRepo{Path: filepath.Join(root, "edited"), Prefix: "edited", Corpus: Corpus{Label: "edited"}} + window := foreignImpactWindow{ID: "active", EditedRepo: "edited", Start: time.Now().Add(-time.Second), End: time.Now().Add(time.Second)} + for _, tc := range []struct { + name string + client *socketClient + edited privateRepo + }{ + {"borrowed session", &socketClient{cwd: edited.Path}, edited}, + {"same repository", &socketClient{cwd: target.Path}, target}, + {"nil session", nil, edited}, + } { + t.Run(tc.name, func(t *testing.T) { + row := observeForeignImpact(t.Context(), tc.client, target, tc.edited, window) + if row.Error == "" || row.RPCID != 0 || row.Latency.Value != nil || row.WithinWindow { + t.Fatalf("invalid route supplied evidence: %+v", row) + } + }) + } +} + +func TestForeignImpactWindowKeepsFailuresAndBoundaryCalls(t *testing.T) { + start := time.Now() + window := foreignImpactWindow{ID: "active", EditedRepo: "edited", Start: start, End: start.Add(time.Minute)} + rows := []foreignImpactSample{ + foreignImpactObservation(window, "target", "edited", "target/a.go::A", json.RawMessage(`{"isError":false,"content":[{"text":"{}"}]}`), wireCallIdentity{ID: 1, Started: start, Finished: start.Add(time.Millisecond)}, nil), + foreignImpactObservation(window, "target", "edited", "target/a.go::A", nil, wireCallIdentity{ID: 2, Started: start.Add(time.Second), Finished: start.Add(2 * time.Second)}, context.DeadlineExceeded), + foreignImpactObservation(window, "target", "edited", "target/a.go::A", nil, wireCallIdentity{ID: 3, Started: start.Add(-time.Second), Finished: start.Add(time.Second)}, errors.New("connection lost")), + foreignImpactObservation(window, "target", "edited", "target/a.go::A", nil, wireCallIdentity{ID: 4, Started: start.Add(59 * time.Second), Finished: start.Add(61 * time.Second)}, errors.New("late error")), + } + summary := summarizeForeignImpact(rows) + if summary.Offered != 4 || summary.WithinWindow != 2 || summary.OutsideWindow != 2 || summary.Errors != 3 || summary.Latency.Count != 2 { + t.Fatalf("hidden exclusion/boundary: %+v", summary) + } + missing := summarizeForeignImpact([]foreignImpactSample{{Error: "no connection", Latency: unavailable("ms", "no RPC")}}) + if missing.Latency.P95.Value != nil { + t.Fatalf("missing observation became zero: %+v", missing) + } + +} + +func TestForeignImpactWaitDecoderUsesOnlyObservedCompleteProducer(t *testing.T) { + for _, tc := range []struct { + state string + attempted, acquired float64 + want bool + }{ + {"observed", 1, 1, true}, {"not_applicable_detached", 0, 0, false}, {"not_observed", 0, 0, false}, {"censored", 1, 0, false}, {"observed", 0, 0, false}, {"observed", 2, 1, false}, + } { + t.Run(tc.state, func(t *testing.T) { + meta := map[string]any{"reach_coordination": map[string]any{"resolve_mutex_wait": map[string]any{"state": tc.state, "attempted": tc.attempted, "acquired": tc.acquired, "total_ms": float64(4)}}} + got := foreignImpactWait(meta, "resolve_mutex_wait") + if (got.Value != nil) != tc.want { + t.Fatalf("producer state %s became %+v", tc.state, got) + } + if got.Value != nil && *got.Value != 4 { + t.Fatalf("producer duration altered: %+v", got) + } + }) + } +} + +func TestForeignImpactPreservesStructuredRPCRefusalData(t *testing.T) { + start := time.Now() + data := map[string]any{"error_code": "graph_freshness_unavailable", "details": map[string]any{"pending_files": []any{"target/a.go"}}} + remote := &rpcError{Code: -32000, Message: "stale graph", Data: data} + row := foreignImpactObservation(foreignImpactWindow{ID: "active", EditedRepo: "edited", Start: start, End: start.Add(time.Minute)}, "target", "edited", "target/a.go::A", nil, wireCallIdentity{ID: 1, Started: start, Finished: start.Add(time.Millisecond)}, remote) + if !row.Refused || row.ErrorCode == nil || *row.ErrorCode != -32000 || row.ErrorData["error_code"] != "graph_freshness_unavailable" { + t.Fatalf("typed refusal lost: %+v", row) + } + data["error_code"] = "mutated" + data["details"].(map[string]any)["pending_files"].([]any)[0] = "other.go" + if row.ErrorData["error_code"] != "graph_freshness_unavailable" || row.ErrorData["details"].(map[string]any)["pending_files"].([]any)[0] != "target/a.go" { + t.Fatal("retained refusal borrowed mutable RPC data") + } +} + +func TestForeignImpactStreamFailsClosedWhenRawEvidenceCannotBeRetained(t *testing.T) { + root := t.TempDir() + target := privateRepo{Path: filepath.Join(root, "target"), Prefix: "target", Corpus: Corpus{Label: "target", Symbol: "a.go::A"}} + edited := privateRepo{Path: filepath.Join(root, "edited"), Prefix: "edited", Corpus: Corpus{Label: "edited"}} + socket := startStub(t, func(f stubFrame) (any, *rpcError) { + return map[string]any{"content": []any{map[string]any{"type": "text", "text": "{}"}}}, nil + }) + client, err := openMCP(t.Context(), socket, target.Path) + if err != nil { + t.Fatal(err) + } + defer client.Close() + window := foreignImpactWindow{ID: "active", EditedRepo: "edited", Start: time.Now(), End: time.Now().Add(time.Minute)} + emitted := 0 + diskErr := errors.New("raw evidence writer failed") + summary, err := runForeignImpactStream(t.Context(), []foreignImpactTarget{{Client: client, Repo: target}}, edited, window, func(row foreignImpactSample) error { + emitted++ + if row.RPCID == 0 || len(row.Raw) == 0 { + t.Error("writer received no actual RPC evidence") + } + return diskErr + }) + if !errors.Is(err, diskErr) || emitted != 1 || summary.Offered != 1 { + t.Fatalf("evidence failure was hidden: emitted=%d err=%v", emitted, err) + } +} + +func TestForeignImpactStreamRotatesDistinctOwnedTargets(t *testing.T) { + root := t.TempDir() + edited := privateRepo{Path: filepath.Join(root, "edited"), Prefix: "edited", Corpus: Corpus{Label: "edited"}} + socket := startStub(t, func(f stubFrame) (any, *rpcError) { + return map[string]any{"content": []any{map[string]any{"type": "text", "text": "{}"}}}, nil + }) + var targets []foreignImpactTarget + for _, name := range []string{"target-one", "target-two"} { + repo := privateRepo{Path: filepath.Join(root, name), Prefix: name, Corpus: Corpus{Label: name, Symbol: "a.go::A"}} + client, err := openMCP(t.Context(), socket, repo.Path) + if err != nil { + t.Fatal(err) + } + defer client.Close() + targets = append(targets, foreignImpactTarget{Client: client, Repo: repo}) + } + stop := errors.New("three-row diagnostic complete") + var order []string + window := foreignImpactWindow{ID: "active", EditedRepo: "edited", Start: time.Now(), End: time.Now().Add(time.Minute)} + summary, err := runForeignImpactStream(t.Context(), targets, edited, window, func(row foreignImpactSample) error { + order = append(order, row.TargetRepo) + if len(order) == 3 { + return stop + } + return nil + }) + if len(summary.Pairs) != 2 || summary.Pairs[0].Summary.Offered != 2 || summary.Pairs[1].Summary.Offered != 1 { + t.Fatalf("pooled queries hid individual pair denominators: %+v", summary.Pairs) + } + if !errors.Is(err, stop) || summary.Offered != 3 || len(order) != 3 || order[0] != "target-one" || order[1] != "target-two" || order[2] != "target-one" { + t.Fatalf("query targets were not independently rotated: order=%v summary=%+v err=%v", order, summary, err) + } +} diff --git a/bench/load/gates.go b/bench/load/gates.go index 0489b7ebe..aa851a35b 100644 --- a/bench/load/gates.go +++ b/bench/load/gates.go @@ -127,7 +127,11 @@ func (p *privateRun) writeBootRepos(worktrees bool) ([]privateRepo, error) { for _, repo := range configured { entries = append(entries, map[string]string{"path": repo.Path, "name": repo.Corpus.Label}) } - data, err := json.Marshal(map[string]any{"repos": entries}) + payload := map[string]any{"repos": entries} + if p.editACKMode != "" { + payload["edit"] = map[string]string{"ack": p.editACKMode} + } + data, err := json.Marshal(payload) if err != nil { return nil, err } diff --git a/bench/load/lifecycle.go b/bench/load/lifecycle.go index 31b3fb041..58ec90d62 100644 --- a/bench/load/lifecycle.go +++ b/bench/load/lifecycle.go @@ -22,6 +22,7 @@ type privateRepo struct { } type privateRun struct { + editACKMode string discoveryRuntime *discoveryRuntimeLease // optional fresh resume root; nil preserves ordinary ownership discoveryCatalog map[string]discoveryCatalogIdentity // discovery-only observed seed identities Root string diff --git a/bench/load/main.go b/bench/load/main.go index d992f943e..f88414d1d 100644 --- a/bench/load/main.go +++ b/bench/load/main.go @@ -17,9 +17,12 @@ func main() { } func command(args []string) error { - if len(args) > 0 && (args[0] == "run" || args[0] == "paired" || args[0] == "smoke") { + if len(args) > 0 && (args[0] == "run" || args[0] == "paired" || args[0] == "smoke" || args[0] == "foreign-impact") { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() + if args[0] == "foreign-impact" { + return foreignImpactCommand(ctx, args[1:]) + } if args[0] == "paired" { return pairedCommand(ctx, args[1:]) } diff --git a/bench/load/monitor.go b/bench/load/monitor.go index 677655a27..d72ee6d35 100644 --- a/bench/load/monitor.go +++ b/bench/load/monitor.go @@ -19,6 +19,7 @@ type phaseMonitor struct { clientBefore map[string]Metric groupBefore map[string]Metric peaks map[string]Metric + walSamples []float64 // every WAL size a sample found, for the p95 cancel context.CancelFunc done sync.WaitGroup } @@ -55,6 +56,9 @@ func (m *phaseMonitor) observe() { values[key] = measured(float64(info.Size()), "bytes") } } + if wal := values["wal_bytes"]; wal.Value != nil { + m.walSamples = append(m.walSamples, *wal.Value) + } for _, key := range []string{"rss_bytes", "pss_bytes", "rss_anon_bytes", "phys_footprint_bytes", "wal_bytes", "store_bytes"} { value := values[key] old, exists := m.peaks[key] @@ -80,6 +84,9 @@ func (m *phaseMonitor) finish(phase *Phase) { for key, value := range m.peaks { phase.Metrics["peak_"+key] = value } + // One reclaim-sized burst sets the peak; the p95 says where the WAL + // spent the phase. + phase.Metrics["p95_wal_bytes"] = distribution(m.walSamples, "bytes").P95 successes := 0 for _, row := range phase.Attempts { if row.Role == "workload" && row.Outcome == "success" { diff --git a/bench/load/monitor_test.go b/bench/load/monitor_test.go new file mode 100644 index 000000000..904042d67 --- /dev/null +++ b/bench/load/monitor_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type absentSampler struct{} + +func (absentSampler) sample(int) map[string]Metric { return absentProcessMetrics("test sampler") } + +// TestMonitorReportsWALP95 samples a store whose WAL sits at 1000 bytes but +// for one burst: the phase reports the burst as the peak and the steady size +// as the p95, counting only samples that found a WAL file. +func TestMonitorReportsWALP95(t *testing.T) { + store := filepath.Join(t.TempDir(), "store.db") + self, err := os.FindProcess(os.Getpid()) + require.NoError(t, err) + m := &phaseMonitor{ + p: &privateRun{Store: store, process: &exec.Cmd{Process: self}}, + sampler: absentSampler{}, + pid: os.Getpid(), + started: time.Now(), + peaks: make(map[string]Metric), + cancel: func() {}, + } + setWAL := func(size int) { + require.NoError(t, os.WriteFile(store+"-wal", make([]byte, size), 0o600)) + } + m.observe() // no WAL yet: not a sample + for i := range 40 { + setWAL(1000) + m.observe() + if i == 20 { + setWAL(1 << 20) + m.observe() + } + } + setWAL(1000) // finish takes one last sample + phase := &Phase{Metrics: make(map[string]Metric)} + m.finish(phase) + + require.NotNil(t, phase.Metrics["peak_wal_bytes"].Value) + require.Equal(t, float64(1<<20), *phase.Metrics["peak_wal_bytes"].Value) + p95 := phase.Metrics["p95_wal_bytes"] + require.NotNil(t, p95.Value, p95.Reason) + require.Equal(t, float64(1000), *p95.Value) + require.Equal(t, "bytes", p95.Unit) + + t.Run("a phase that never found a WAL", func(t *testing.T) { + m := &phaseMonitor{ + p: &privateRun{Store: filepath.Join(t.TempDir(), "store.db"), process: &exec.Cmd{Process: self}}, + sampler: absentSampler{}, + pid: os.Getpid(), + started: time.Now(), + peaks: make(map[string]Metric), + cancel: func() {}, + } + m.observe() + phase := &Phase{Metrics: make(map[string]Metric)} + m.finish(phase) + require.Nil(t, phase.Metrics["p95_wal_bytes"].Value) + require.NotEmpty(t, phase.Metrics["p95_wal_bytes"].Reason) + }) +} diff --git a/bench/load/paired_command.go b/bench/load/paired_command.go index 4125b3926..c21977df8 100644 --- a/bench/load/paired_command.go +++ b/bench/load/paired_command.go @@ -50,6 +50,9 @@ func parsePaired(args []string) (runOptions, []buildSpec, error) { return o, nil, err } o.Binary = builds[0].binary + if o.EditGateSeed != "" { + return o, nil, errors.New("edit gate fault seeds are standalone candidate runs only") + } if err := validateRunOptions(o); err != nil { return o, nil, err } diff --git a/bench/load/plan.go b/bench/load/plan.go index c80add08d..aa2996a00 100644 --- a/bench/load/plan.go +++ b/bench/load/plan.go @@ -63,6 +63,8 @@ type request struct { // Workload is the complete normalized offered load. Binary paths, private // clone paths and revisions belong to the run identity, never the plan hash. type Workload struct { + EditACKMode string `json:"edit_ack_mode,omitempty"` + EditGateSeed string `json:"edit_gate_seed,omitempty"` PRSmokeDrain *PRSmokeDrain `json:"pr_smoke_drain,omitempty"` Scenario string `json:"scenario"` Agents int `json:"agents"` diff --git a/bench/load/run.go b/bench/load/run.go index fe89ad92c..00d8804e6 100644 --- a/bench/load/run.go +++ b/bench/load/run.go @@ -14,6 +14,7 @@ import ( ) type runOptions struct { + EditACKMode string Binary string Manifest string Scratch string @@ -40,6 +41,8 @@ type runOptions struct { SweepBudget time.Duration EditMean time.Duration EditCount int + EditGateModes bool + EditGateSeed string EditRepos int EditProbeRole string editProbeBaseline bool @@ -69,6 +72,7 @@ func parseRun(args []string) (runOptions, error) { } func bindRunFlags(f *flag.FlagSet, o *runOptions) { + f.StringVar(&o.EditACKMode, "edit-ack", "", "owned daemon edit.ack policy: local or full; omit to preserve the full default") f.StringVar(&o.Binary, "binary", "", "Gortex binary (never an existing daemon)") f.StringVar(&o.Manifest, "manifest", "", "pinned JSON corpus manifest; source roots are read-only") f.StringVar(&o.Scratch, "scratch", "", "artifact/private-clone parent outside all source roots") @@ -95,10 +99,21 @@ func bindRunFlags(f *flag.FlagSet, o *runOptions) { f.IntVar(&o.EditRepos, "edit-repos", 1, "edit-and-probe: one repository (default), or exactly six distinct pinned repositories") bindDiscoveryFlags(f, &o.Discovery) f.IntVar(&o.EditCount, "edit-count", 100, "edit-and-probe: 100 logical edits, or explicit 1-edit diagnostic") + f.BoolVar(&o.EditGateModes, "edit-gate-modes", false, "edit-and-probe gate: rotate five probe surfaces over 100 total edits; JSON ACKs remain unchanged") + f.StringVar(&o.EditGateSeed, "edit-gate-seed", "", "explicit gate-only fault: ack-wait or closed-read-gate; requires a seed-tagged binary") f.StringVar(&o.EditProbeRole, "edit-probe-role", "", "edit-and-probe standalone role: candidate (default; phase accounting required) or baseline (absent phase timings reported unavailable)") } func validateRunOptions(o runOptions) error { + if o.EditACKMode != "" && o.EditACKMode != "local" && o.EditACKMode != "full" { + return errors.New("--edit-ack must be local or full when explicitly set") + } + if o.EditGateSeed != "" && (!o.EditGateModes || o.EditGateSeed != "ack-wait" && o.EditGateSeed != "closed-read-gate") { + return errors.New("--edit-gate-seed requires --edit-gate-modes and ack-wait or closed-read-gate") + } + if o.EditGateModes && (o.Scenario != editProbeScenario || o.EditCount != 100 || o.Agents != 0) { + return errors.New("--edit-gate-modes requires edit-and-probe, 100 total edits and no extra pure readers") + } if o.EditMean < 0 || o.EditMean > 0 && o.Scenario != editProbeScenario { return errors.New("--edit-mean requires edit-and-probe and a nonnegative duration; 0 uses the 15s default") } @@ -305,6 +320,8 @@ func newRunReport(o runOptions, corpora []Corpus, sha string, host Host, outputR } report.Workload.SessionModel = plan.SessionModel } + report.Workload.EditGateSeed = o.EditGateSeed + report.Workload.EditACKMode = o.EditACKMode report.Workload.Scenario, report.Workload.Agents, report.Workload.EditAgents, report.Workload.LinkedWorktrees = o.Scenario, o.Agents, o.Editors, o.Worktrees report.Workload.DurationSec, report.Workload.DeadlineSec, report.Workload.Seed, report.Workload.Profile = o.Duration.Seconds(), o.Deadline.Seconds(), o.Seed, o.Profile if o.PRSmokeDrain > 0 { diff --git a/bench/load/scenario.go b/bench/load/scenario.go index 68c3ea622..fffc2a6c5 100644 --- a/bench/load/scenario.go +++ b/bench/load/scenario.go @@ -14,6 +14,14 @@ import ( ) func runPhase(ctx context.Context, o runOptions, scenario string, corpora []Corpus, group, outputRoot string) (phase Phase, sweep []SweepEntry, artifacts map[string]string, crashes map[string]int, version string, resultErr error) { + seedEnv, err := editGateSeedEnvironment(o) + if err != nil { + return phase, nil, nil, nil, "", err + } + var gateWallStart time.Time + if o.EditGateModes { + gateWallStart = time.Now() + } if scenario == startupScenario { return runStartupPhase(ctx, o, corpora, outputRoot) } @@ -63,6 +71,16 @@ func runPhase(ctx context.Context, o runOptions, scenario string, corpora []Corp if resultErr == nil && len(phase.Errors) > 0 { resultErr = errors.New(strings.Join(phase.Errors, "; ")) } + if o.EditGateModes { + // This first-registered defer runs after owned cleanup, so readiness, + // measurement and normal stop all remain in the wall denominator. + wall := struct { + Clock string `json:"clock"` + NS int64 `json:"wall_ns"` + Complete bool `json:"complete"` + }{"monotonic", time.Since(gateWallStart).Nanoseconds(), resultErr == nil && len(phase.Errors) == 0} + phase.Gate["edit_loop_wall"], _ = json.Marshal(wall) + } }() worktreeViews := scenario == "worktrees" || scenario == editProbeScenario && o.Worktrees > 0 if scenario == editProbeScenario && (o.EditRepos > 1 || o.Worktrees > 0) { @@ -116,7 +134,6 @@ func runPhase(ctx context.Context, o runOptions, scenario string, corpora []Corp phase.SessionModel = plan.SessionModel } p := o.smokeState - var err error if p == nil { if scenario == checkoutDiscoveryScenario && o.Discovery.Resume != "" { p, err = resumeDiscoveryState(ctx, o, selected) @@ -129,6 +146,7 @@ func runPhase(ctx context.Context, o runOptions, scenario string, corpora []Corp if err != nil { return phase, nil, artifacts, crashes, "", err } + p.Env = append(p.Env, seedEnv...) artifacts["private_root"] = p.Root if scenario == checkoutDiscoveryScenario && o.Discovery.Resume != "" { raw, err := os.ReadFile(o.Discovery.Resume) @@ -195,6 +213,7 @@ func runPhase(ctx context.Context, o runOptions, scenario string, corpora []Corp } warmCtx, cancelWarm := context.WithTimeout(ctx, o.WarmTimeout) defer cancelWarm() + p.editACKMode = o.EditACKMode var configured []privateRepo if scenario == checkoutDiscoveryScenario { configured, err = p.writeDiscoveryBootRepos() diff --git a/bench/load/startup_probe.go b/bench/load/startup_probe.go index 37bc3f4fc..86b3db3d0 100644 --- a/bench/load/startup_probe.go +++ b/bench/load/startup_probe.go @@ -80,6 +80,7 @@ func runStartupPhase(ctx context.Context, o runOptions, corpora []Corpus, output if err != nil { return phase, nil, artifacts, crashes, "", err } + p.editACKMode = o.EditACKMode artifacts["private_root"] = p.Root defer func() { stopCtx, cancel := context.WithTimeout(context.Background(), o.StopTimeout) diff --git a/bench/load/testdata/paired-v1.schema.json b/bench/load/testdata/paired-v1.schema.json index 305f9f633..dfddd844f 100644 --- a/bench/load/testdata/paired-v1.schema.json +++ b/bench/load/testdata/paired-v1.schema.json @@ -635,6 +635,16 @@ ], "type": "object" }, + "ack_readback_exact": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, "activated_ns": { "anyOf": [ { @@ -645,6 +655,16 @@ } ] }, + "admitted_target_lines": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "batch": { "anyOf": [ { @@ -1038,6 +1058,49 @@ "mutation_receipt": { "type": "string" }, + "mutation_tiers": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "ack_at": { + "type": "string" + }, + "derived_at": { + "type": "string" + }, + "file_graph_at": { + "type": "string" + }, + "observation_state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "relations_at": { + "type": "string" + }, + "tier_state": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "observation_state" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, "native_write": { "anyOf": [ { @@ -1249,6 +1312,9 @@ "probe_errors": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "raw_evidence": { "type": "string" }, @@ -1576,6 +1642,40 @@ ], "type": "object" }, + "t_gate_readback_ms": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "reason": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "value", + "unit", + "reason" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, "t_readback_ms": { "additionalProperties": false, "properties": { @@ -1713,6 +1813,16 @@ "decode_failures": { "type": "integer" }, + "observed_edits": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "precommit_fresh_mismatches_unassessed": { "type": "integer" }, @@ -1751,6 +1861,67 @@ }, "state": { "type": "string" + }, + "tools": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "claims_fresh": { + "type": "integer" + }, + "correct_edits": { + "type": "integer" + }, + "decode_failures": { + "type": "integer" + }, + "fallback_answers": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "observed_edits": { + "type": "integer" + }, + "probe_answers": { + "type": "integer" + }, + "reason": { + "type": "string" + }, + "refused_edits": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "state": { + "type": "string" + } + }, + "required": [ + "state", + "correct_edits", + "probe_answers", + "observed_edits", + "decode_failures", + "claims_fresh" + ], + "type": "object" + }, + "type": [ + "object", + "null" + ] } }, "required": [ @@ -1790,6 +1961,9 @@ "file": { "type": "string" }, + "gate_modes": { + "type": "boolean" + }, "kind": { "type": "string" }, @@ -1941,6 +2115,9 @@ "ordinal": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "relocated": { "type": "boolean" }, @@ -3605,9 +3782,15 @@ "duration_seconds": { "type": "number" }, + "edit_ack_mode": { + "type": "string" + }, "edit_agents": { "type": "integer" }, + "edit_gate_seed": { + "type": "string" + }, "edit_probe": { "anyOf": [ { @@ -3631,6 +3814,9 @@ "file": { "type": "string" }, + "gate_modes": { + "type": "boolean" + }, "kind": { "type": "string" }, @@ -3782,6 +3968,9 @@ "ordinal": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "relocated": { "type": "boolean" }, diff --git a/bench/load/testdata/report-v1.schema.json b/bench/load/testdata/report-v1.schema.json index 94b97962f..1f444d83e 100644 --- a/bench/load/testdata/report-v1.schema.json +++ b/bench/load/testdata/report-v1.schema.json @@ -622,6 +622,16 @@ ], "type": "object" }, + "ack_readback_exact": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, "activated_ns": { "anyOf": [ { @@ -632,6 +642,16 @@ } ] }, + "admitted_target_lines": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "batch": { "anyOf": [ { @@ -1025,6 +1045,49 @@ "mutation_receipt": { "type": "string" }, + "mutation_tiers": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "ack_at": { + "type": "string" + }, + "derived_at": { + "type": "string" + }, + "file_graph_at": { + "type": "string" + }, + "observation_state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "relations_at": { + "type": "string" + }, + "tier_state": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "observation_state" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, "native_write": { "anyOf": [ { @@ -1236,6 +1299,9 @@ "probe_errors": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "raw_evidence": { "type": "string" }, @@ -1563,6 +1629,40 @@ ], "type": "object" }, + "t_gate_readback_ms": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "reason": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "value", + "unit", + "reason" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, "t_readback_ms": { "additionalProperties": false, "properties": { @@ -1700,6 +1800,16 @@ "decode_failures": { "type": "integer" }, + "observed_edits": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "precommit_fresh_mismatches_unassessed": { "type": "integer" }, @@ -1738,6 +1848,67 @@ }, "state": { "type": "string" + }, + "tools": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "claims_fresh": { + "type": "integer" + }, + "correct_edits": { + "type": "integer" + }, + "decode_failures": { + "type": "integer" + }, + "fallback_answers": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "observed_edits": { + "type": "integer" + }, + "probe_answers": { + "type": "integer" + }, + "reason": { + "type": "string" + }, + "refused_edits": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "state": { + "type": "string" + } + }, + "required": [ + "state", + "correct_edits", + "probe_answers", + "observed_edits", + "decode_failures", + "claims_fresh" + ], + "type": "object" + }, + "type": [ + "object", + "null" + ] } }, "required": [ @@ -1777,6 +1948,9 @@ "file": { "type": "string" }, + "gate_modes": { + "type": "boolean" + }, "kind": { "type": "string" }, @@ -1928,6 +2102,9 @@ "ordinal": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "relocated": { "type": "boolean" }, @@ -3592,9 +3769,15 @@ "duration_seconds": { "type": "number" }, + "edit_ack_mode": { + "type": "string" + }, "edit_agents": { "type": "integer" }, + "edit_gate_seed": { + "type": "string" + }, "edit_probe": { "anyOf": [ { @@ -3618,6 +3801,9 @@ "file": { "type": "string" }, + "gate_modes": { + "type": "boolean" + }, "kind": { "type": "string" }, @@ -3769,6 +3955,9 @@ "ordinal": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "relocated": { "type": "boolean" }, diff --git a/cmd/gortex/call.go b/cmd/gortex/call.go index 7546348b3..401fe8792 100644 --- a/cmd/gortex/call.go +++ b/cmd/gortex/call.go @@ -100,6 +100,7 @@ func runCall(cmd *cobra.Command, args []string) error { if err != nil { return err } + argObj = applyCLIRequireFresh(argObj) compactCall := gortexmcp.IsFacadeToolName(tool) && !callLegacy formatExplicit := callFormat != "json" @@ -144,6 +145,9 @@ func runCall(cmd *cobra.Command, args []string) error { relay = callFacadeDaemonTool } raw, err := relay(callIndex, tool, argObj) + if cliRequireFresh { + err = checkCLIFreshness(cmd, raw, err) + } if err != nil { return err } diff --git a/cmd/gortex/cli_freshness.go b/cmd/gortex/cli_freshness.go new file mode 100644 index 000000000..cea2085f2 --- /dev/null +++ b/cmd/gortex/cli_freshness.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var cliRequireFresh bool + +func applyCLIRequireFresh(args map[string]any) map[string]any { + if cliRequireFresh { + if args == nil { + args = make(map[string]any) + } + args["require_fresh"] = true + } + return args +} + +func checkCLIFreshness(cmd *cobra.Command, raw json.RawMessage, callErr error) error { + var fields map[string]any + _ = json.Unmarshal(raw, &fields) + if nested, ok := fields["freshness"].(map[string]any); ok { + fields = nested + } + state, _ := fields["graph_freshness"].(string) + scope, _ := fields["freshness_scope"].(string) + if state == "" { + stale, _ := fields["stale_files"].([]any) + missing, _ := fields["missing_files"].([]any) + count := len(stale) + len(missing) + if count > 0 || fields["stale"] == true || fields["missing"] == true || fields["index_frozen"] == true { + state, scope = "pending", "file" + fields["pending_file_count"] = max(1, count) + } + } + line := "" + if state != "" && state != "fresh" { + if scope == "" { + scope = "relations" + } + count := fields["pending_file_count"] + if count == nil { + count = 0 + } + line = fmt.Sprintf("%s: %s (%v files)", scope, state, count) + } else { + text := string(raw) + if callErr != nil { + var labelled bool + text, labelled = strings.CutPrefix(callErr.Error(), "require_fresh: ") + if !labelled { + return callErr + } + } else if json.Valid(raw) { + return nil // source strings inside JSON are not freshness metadata + } + text, _, _ = strings.Cut(text, "\n") + for _, tier := range []string{"file", "relations", "derived"} { + for _, status := range []string{"pending", "failed", "unknown"} { + prefix := tier + ": " + status + " (" + if tail, ok := strings.CutPrefix(text, prefix); ok { + if count, _, closed := strings.Cut(tail, " files)"); closed { + line = prefix + count + " files)" + } + } + } + } + } + if line == "" { + return callErr + } + fmt.Fprintln(cmd.ErrOrStderr(), line) + if cliRequireFresh { + cmd.SilenceErrors = true + return &exitCodeError{code: 4} + } + return callErr +} diff --git a/cmd/gortex/cli_freshness_content_test.go b/cmd/gortex/cli_freshness_content_test.go new file mode 100644 index 000000000..f254ab49b --- /dev/null +++ b/cmd/gortex/cli_freshness_content_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestCLIRequireFreshIgnoresRelationsLabelInsideSourceContent(t *testing.T) { + prior := cliRequireFresh + cliRequireFresh = true + t.Cleanup(func() { cliRequireFresh = prior }) + body, err := json.Marshal(map[string]any{ + "path": "example.go", + "content": "package example\n// relations: pending (2 files)\nfunc Fresh() {}\n", + }) + require.NoError(t, err) + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetErr(&stderr) + require.NoError(t, checkCLIFreshness(cmd, body, nil), "source text is not freshness metadata") + require.Empty(t, stderr.String(), "fresh source content must not produce a stale diagnostic") + require.False(t, cmd.SilenceErrors) +} diff --git a/cmd/gortex/cli_freshness_test.go b/cmd/gortex/cli_freshness_test.go new file mode 100644 index 000000000..caad29af7 --- /dev/null +++ b/cmd/gortex/cli_freshness_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "bytes" + "errors" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestCLIRequireFreshPrintsLineAndExits(t *testing.T) { + prior := cliRequireFresh + cliRequireFresh = true + t.Cleanup(func() { cliRequireFresh = prior }) + for _, nested := range []bool{false, true} { + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetErr(&stderr) + body := []byte(`{"freshness_scope":"relations","graph_freshness":"pending","pending_file_count":2}`) + if nested { + body = append(append([]byte(`{"freshness":`), body...), '}') + } + err := checkCLIFreshness(cmd, body, nil) + var exit *exitCodeError + require.ErrorAs(t, err, &exit) + require.Equal(t, 4, exit.ExitCode()) + require.Equal(t, "relations: pending (2 files)\n", stderr.String()) + } + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetErr(&stderr) + err := checkCLIFreshness(cmd, nil, errors.New("require_fresh: relations: failed (1 files) after waiting 25 ms")) + var exit *exitCodeError + require.ErrorAs(t, err, &exit) + require.Equal(t, 4, exit.ExitCode()) + require.Equal(t, "relations: failed (1 files)\n", stderr.String()) + require.Equal(t, true, applyCLIRequireFresh(nil)["require_fresh"]) +} + +func TestCLIRequireFreshHonorsStructuredFileDrift(t *testing.T) { + prior := cliRequireFresh + cliRequireFresh = true + t.Cleanup(func() { cliRequireFresh = prior }) + for name, test := range map[string]struct { + body string + line string + }{ + "stale": {`{"freshness":{"stale":true}}`, "file: pending (1 files)\n"}, + "missing": {`{"freshness":{"missing":true}}`, "file: pending (1 files)\n"}, + "frozen": {`{"freshness":{"index_frozen":true}}`, "file: pending (1 files)\n"}, + "list": {`{"freshness":{"stale_files":[{"file":"a.go"}],"missing_files":[{"file":"b.go"}]}}`, "file: pending (2 files)\n"}, + } { + t.Run(name, func(t *testing.T) { + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetErr(&stderr) + err := checkCLIFreshness(cmd, []byte(test.body), nil) + var exit *exitCodeError + require.ErrorAs(t, err, &exit) + require.Equal(t, 4, exit.ExitCode()) + require.Equal(t, test.line, stderr.String()) + }) + } +} diff --git a/cmd/gortex/daemon.go b/cmd/gortex/daemon.go index c5f5d30e4..6eb5fd2c4 100644 --- a/cmd/gortex/daemon.go +++ b/cmd/gortex/daemon.go @@ -286,16 +286,53 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { // the parent did. daemonEmbeddingsChanged = cmd.Flags().Changed("embeddings") - // Fast path: open the store + wire the indexer and MCP server. The - // per-repo TrackRepoCtx loop and MultiWatcher init are deferred to - // warmupDaemonState below so the socket opens immediately instead - // of waiting 30–60s for contract re-extraction across every tracked - // repo. - state, err := buildDaemonState(logger, startupReporter.ObserveMigration) + // The existing socket owner serves metadata before Store.Open can + // migrate. No graph/catalog/CWD admission runs in this temporary gate. + metadataConfig, err := config.Load(cfgFile) + if err != nil { + startupReporter.Fail(err) + return fmt.Errorf("build daemon state: load config: %w", err) + } + applyToolPresetFlags(metadataConfig, daemonTools, daemonToolsMode) + metadata := newMigrationMetadataDispatcher(metadataConfig, logger, startupReporter) + srv.MCPDispatcher = metadata + srv.Ready = func() (bool, string) { return false, metadata.phase() } + srv.CancelShutdownOwners, srv.JoinRuntimeWriter = startupReporter.Cancel, startupReporter.Stop + srv.EnableStartupMetadata() + httpAddr := resolveDaemonHTTPAddr(daemonHTTPAddr, os.Getenv("GORTEX_DAEMON_HTTP_ADDR"), gc) + var httpHandoff *migrationHTTPHandler + if httpAddr != "" { + token := daemonHTTPAuthToken + if token == "" { + token = os.Getenv("GORTEX_DAEMON_HTTP_TOKEN") + } + if err := httpTokenRequirementError(httpAddr, token); err != nil { + startupReporter.Fail(err) + return err + } + httpHandoff = &migrationHTTPHandler{available: srv.StartupPublished} + srv.HTTPAddr, srv.HTTPHandler = httpAddr, httpHandoff + } + if err := srv.Listen(); err != nil { + startupReporter.Fail(err) + return err + } + serveDone := make(chan struct{}) + var serveErr error + go func() { serveErr = srv.Serve(); close(serveDone) }() + defer func() { _ = srv.Shutdown(); <-serveDone }() + + state, err := buildDaemonStateForStart(logger, startupReporter.ObserveMigration) if err != nil { startupReporter.Fail(err) return fmt.Errorf("build daemon state: %w", err) } + if srv.StartupClosed() { + if state.shared != nil { + return state.shared.Close() + } + return nil + } // Install the standing soft memory limit now — logging and config are // up and no warmup / indexing has allocated yet, so the cold-index @@ -421,9 +458,6 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { } retirementWorker.Stop() } - srv.CancelShutdownOwners = cancelBackground - srv.JoinRuntimeWriter = joinStartupReporter - srv.RuntimeStateFile = daemon.RuntimeStatePath() runTeardown := installDaemonTeardown(controller, stopBackground, func() error { shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 30*time.Second) defer cancelShutdown() @@ -450,12 +484,11 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { }, logger) }) defer runTeardown() - srv.Controller = controller // Surface warmup state on the handshake ack: a proxy / CLI that connects // during the (minutes-long) warmup should know the graph is still filling // instead of guessing. controller.IsReady() is authoritative for the bool; // the readiness broadcaster carries the fine-grained phase name. - srv.Ready = func() (bool, string) { + readyRuntime := func() (bool, string) { ready := controller.IsReady() phase := "warming" if ready { @@ -520,7 +553,6 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { router: disp.Router, }) } - srv.MCPDispatcher = disp // Event hub feeding the /v1 REST surface's graph-change SSE stream // (/v1/events). Created lazily when the HTTP surface is enabled below @@ -540,7 +572,6 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { // it. The auth token is mandatory for non-localhost binds — // exposing an unauthenticated MCP server on an external // interface is a footgun, not a feature. - httpAddr := resolveDaemonHTTPAddr(daemonHTTPAddr, os.Getenv("GORTEX_DAEMON_HTTP_ADDR"), state.configManager.Global()) if httpAddr != "" { token := daemonHTTPAuthToken if token == "" { @@ -602,8 +633,7 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { v1.SetConversationDir(conversationlog.DirFromEnv()) v1.SetConversationGuard(daemonHTTPConversationAllow, tokenFn) - srv.HTTPHandler = composeDaemonHTTPHandler(streamH, v1, tokenFn, daemonHTTPCORSOrigin) - srv.HTTPAddr = httpAddr + httpHandoff.publish(composeDaemonHTTPHandler(streamH, v1, tokenFn, daemonHTTPCORSOrigin)) logger.Info("daemon: HTTP surface configured (/mcp + /v1)", zap.String("addr", httpAddr), zap.Bool("authenticated", token != ""), @@ -656,15 +686,11 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { state.lifecycle.EnableDeferredSeedRetirements() } - if err := listenConfiguredDaemon(srv, state); err != nil { + declareConfiguredDaemonRepos(state) + if err := srv.PublishStartupRuntime(controller, metadata.handoff(disp), readyRuntime, cancelBackground, joinStartupReporter, daemon.RuntimeStatePath()); err != nil { startupReporter.Fail(err) return err } - defer func() { - if err := srv.Shutdown(); err != nil { - logger.Warn("daemon: runtime teardown failed", zap.Error(err)) - } - }() // The socket remains the authoritative readiness signal. The runtime // record now transitions from pre-socket progress to serving only after // Listen succeeds, and keeps the resolved backend path for out-of-band @@ -846,7 +872,7 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { // once-guarded backend teardown is reached. defer warmupTask.cancel() defer compactionTask.cancel() - serveErr := srv.Serve() + <-serveDone // Record a real error before deferred cleanup can complete both receipts. // The watchdog must preserve normal error exit codes if an outer finalizer // subsequently blocks. The usual execute error path remains unchanged. @@ -1145,6 +1171,11 @@ func spawnDetachedDaemon(childArgs []string) error { case werr := <-exited: failMsg := fmt.Errorf("daemon exited during startup (%v); check %s and %s", werr, daemon.LogFilePath(), daemon.StderrLogFilePath()) + st, stateOK := daemon.ReadRuntimeStateOf(child.Process.Pid) + if reason, recorded := daemonStartupFailure(st, stateOK, child.Process.Pid); recorded { + failMsg = fmt.Errorf("daemon exited during startup: %s (%v); check %s and %s", + reason, werr, daemon.LogFilePath(), daemon.StderrLogFilePath()) + } if sp != nil { sp.Fail(failMsg) } @@ -1160,6 +1191,18 @@ func spawnDetachedDaemon(childArgs []string) error { return timeoutErr } +// daemonStartupFailure returns the reason a startup child recorded before it +// exited, when that reason says more than the generic pointer to the log. +func daemonStartupFailure(st daemon.RuntimeState, ok bool, childPID int) (string, bool) { + if !ok || st.PID != childPID || st.StartupPhase != daemon.StartupFailed { + return "", false + } + if st.StartupError == "" || st.StartupError == startupFailedMessage { + return "", false + } + return st.StartupError, true +} + func daemonStartupProgress(st daemon.RuntimeState, ok bool, childPID int, now time.Time, freshness time.Duration) (string, bool) { if !ok || st.PID != childPID || !st.StartupProgressFresh(now, freshness) { return "", false diff --git a/cmd/gortex/daemon_migration_metadata.go b/cmd/gortex/daemon_migration_metadata.go new file mode 100644 index 000000000..1dc2c1ebf --- /dev/null +++ b/cmd/gortex/daemon_migration_metadata.go @@ -0,0 +1,174 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "sync" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/daemon" + gortexmcp "github.com/zzet/gortex/internal/mcp" +) + +// These records retain connection protocol state only, never cwd ownership. +// An old accepted connection is attached to the actual server on its first +// post-migration frame, without reconnecting or serializing other sessions. +type migrationMetadataSession struct { + mu sync.Mutex + session *daemon.Session + write func([]byte) error + initialize, initialized []byte + bound *mcpDispatcher + closed bool +} + +type migrationMetadataDispatcher struct { + catalog *mcpDispatcher + target *mcpDispatcher // immutable in a published dispatcher + reporter *daemonStartupReporter + sessions *sync.Map +} + +func newMigrationMetadataDispatcher(cfg *config.Config, logger *zap.Logger, reporter *daemonStartupReporter) *migrationMetadataDispatcher { + policy := gortexmcp.ToolPolicyConfig{Preset: cfg.MCP.Tools.Preset, Mode: cfg.MCP.Tools.Mode, Allow: cfg.MCP.Tools.Allow, Deny: cfg.MCP.Tools.Deny, OperatorPinned: cfg.MCP.Tools.Explicit} + // Metadata registration needs no graph or query engine. Every graph call + // is refused by this dispatcher until the real durable runtime is handed + // over, so constructing an in-memory persistence substitute is unnecessary. + server := gortexmcp.NewServer(nil, nil, nil, nil, logger, nil, gortexmcp.MultiRepoOptions{ToolPolicy: &policy}) + return &migrationMetadataDispatcher{catalog: newMCPDispatcher(server, nil, logger), reporter: reporter, sessions: &sync.Map{}} +} + +func (d *migrationMetadataDispatcher) handoff(target *mcpDispatcher) *migrationMetadataDispatcher { + return &migrationMetadataDispatcher{catalog: d.catalog, target: target, reporter: d.reporter, sessions: d.sessions} +} + +func (d *migrationMetadataDispatcher) DispatchClass(name string, args map[string]any) daemon.MCPDispatchClass { + if d.target != nil { + return d.target.DispatchClass(name, args) + } + return d.catalog.DispatchClass(name, args) +} + +func (d *migrationMetadataDispatcher) SessionStarted(session *daemon.Session, write func([]byte) error) { + rec := &migrationMetadataSession{session: session, write: write} + rec.mu.Lock() + defer rec.mu.Unlock() + d.sessions.Store(session.ID, rec) + if d.target != nil { + d.target.SessionStarted(session, write) + rec.bound = d.target + } else { + d.catalog.SessionStarted(session, write) + } +} + +func (d *migrationMetadataDispatcher) SessionEnded(session *daemon.Session) { + value, ok := d.sessions.Load(session.ID) + if !ok { + return + } + rec := value.(*migrationMetadataSession) + rec.mu.Lock() + defer rec.mu.Unlock() + if rec.session != session || !d.sessions.CompareAndDelete(session.ID, value) { + return + } + rec.closed = true + if rec.bound != nil { + rec.bound.SessionEnded(session) + } + d.catalog.SessionEnded(session) +} + +func (d *migrationMetadataDispatcher) phase() string { + d.reporter.mu.Lock() + defer d.reporter.mu.Unlock() + if d.reporter.state.StartupPhase == daemon.StartupMigrating { + return "migrating" + } + return "opening_store" +} + +func (d *migrationMetadataDispatcher) Dispatch(ctx context.Context, session *daemon.Session, frame []byte) ([]byte, error) { + value, ok := d.sessions.Load(session.ID) + if !ok { + return nil, context.Canceled + } + rec := value.(*migrationMetadataSession) + rec.mu.Lock() + if rec.closed { + rec.mu.Unlock() + return nil, context.Canceled + } + if d.target != nil { + if rec.bound != d.target { + d.target.SessionStarted(session, rec.write) + for _, replay := range [][]byte{rec.initialize, rec.initialized} { + if len(replay) > 0 { + if _, err := d.target.Dispatch(ctx, session, replay); err != nil { + rec.mu.Unlock() + return nil, err + } + } + } + d.catalog.SessionEnded(session) + rec.bound = d.target + } + rec.mu.Unlock() + return d.target.Dispatch(ctx, session, frame) + } + method := peekFrameMethod(frame) + if method == "initialize" { + rec.initialize = append([]byte(nil), frame...) + } + if method == "notifications/initialized" { + rec.initialized = append([]byte(nil), frame...) + } + rec.mu.Unlock() + // Metadata never performs cwd, Git, catalog or graph admission. Until the + // store has opened, uncertainty is loading for every graph request. + if !loadingMetadataMethod(method) { + return jsonRPCErrorFrame(frame, "store schema is opening or migrating", map[string]any{"error_code": string(gortexmcp.ErrCodeRepoLoading), "retryable": true, "path": session.CWD, "phase": d.phase(), "percent": 0}), nil + } + srv := d.catalog.srv + ctx = gortexmcp.WithClientConnection(gortexmcp.WithSessionCWD(gortexmcp.WithSessionID(ctx, session.ID), session.CWD)) + ctx = srv.WithClientSession(ctx, session.ID) + srv.NoteSessionToolPolicy(session.ID, session.ToolSpec, session.ToolMode) + d.catalog.maybeSnoopInitialize(session, frame) + reply := srv.MCPServer().HandleMessage(ctx, json.RawMessage(frame)) + if reply == nil { + return nil, nil + } + out, err := json.Marshal(reply) + if err == nil { + out = rewriteMCPAdmissionInstructions(method, out, gortexmcp.ServerInstructionsLoading("configured repositories", d.phase(), 0)) + } + return out, err +} + +// The optional HTTP listener is configured before Listen too. Its ordinary +// handler is published only when the store-backed runtime exists. +type migrationHTTPHandler struct { + mu sync.RWMutex + target http.Handler + available func() bool +} + +func (h *migrationHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + target := h.target + h.mu.RUnlock() + if target == nil || h.available != nil && !h.available() { + http.Error(w, "store is opening or migrating; retry", http.StatusServiceUnavailable) + return + } + target.ServeHTTP(w, r) +} +func (h *migrationHTTPHandler) publish(target http.Handler) { + h.mu.Lock() + h.target = target + h.mu.Unlock() +} diff --git a/cmd/gortex/daemon_migration_socket_test.go b/cmd/gortex/daemon_migration_socket_test.go new file mode 100644 index 000000000..6dc24996f --- /dev/null +++ b/cmd/gortex/daemon_migration_socket_test.go @@ -0,0 +1,217 @@ +//go:build linux || darwin + +package main + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/testenv" +) + +func TestMigrationSocketOwnedHelper(t *testing.T) { + root := os.Getenv("GORTEX_TEST_MIGRATION_SOCKET_ROOT") + if root == "" { + t.Skip("owned child only") + } + require.True(t, filepath.IsAbs(root)) + require.True(t, strings.HasPrefix(filepath.Base(root), "gx"), "helper opens only its parent fresh short fixture") + var owner struct { + ParentPID int `json:"parent_pid"` + } + input, err := os.ReadFile(filepath.Join(root, "migration-owner.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(input, &owner)) + require.Equal(t, os.Getppid(), owner.ParentPID) + for key, value := range migrationProxyOwnedEnv(root) { + t.Setenv(key, value) + } + cfgFile, daemonBackendPath, daemonBackend = filepath.Join(root, "repo.yaml"), filepath.Join(root, "store.sqlite"), "sqlite" + daemonDetach = false + daemonHTTPAddr, daemonHTTPAuthToken, daemonHTTPCORSOrigin = "", "", "" + for _, key := range []string{"GORTEX_DAEMON_HTTP_ADDR", "GORTEX_DAEMON_HTTP_TOKEN", "GORTEX_DAEMON_PPROF_ADDR", "GORTEX_TOOLS", "GORTEX_TOOLS_MODE"} { + t.Setenv(key, "") + } + buildDaemonStateForStart = func(logger *zap.Logger, observers ...store_sqlite.MigrationObserver) (*daemonState, error) { + return buildDaemonState(logger, func(progress store_sqlite.MigrationProgress) { + if len(observers) > 0 { + observers[0](progress) + } + if progress.Version == 40 && progress.Phase == store_sqlite.MigrationStarted { + require.NoError(t, os.WriteFile(filepath.Join(root, "migration-entered"), []byte("real migration v40 started\n"), 0o600)) + deadline := time.Now().Add(20 * time.Second) + for { + if _, err := os.Stat(filepath.Join(root, "release-migration")); err == nil { + break + } + require.True(t, time.Now().Before(deadline), "parent did not release owned migration") + time.Sleep(5 * time.Millisecond) + } + } + }) + } + command := &cobra.Command{} + command.SetContext(t.Context()) + require.NoError(t, runDaemonStart(command, nil)) +} + +func migrationSocketFrame(t *testing.T, c *daemon.Client, frame string, id int) map[string]any { + t.Helper() + require.NoError(t, c.Conn.SetDeadline(time.Now().Add(time.Second))) + require.NoError(t, c.WriteMCPFrame([]byte(frame))) + for { + raw, err := c.ReadMCPFrame() + require.NoError(t, err) + var answer map[string]any + require.NoError(t, json.Unmarshal(raw, &answer)) + if answer["id"] == float64(id) { + return answer + } + } +} + +func TestDaemonMigrationSocketKeepsMetadataAndScopedLoadingAvailable(t *testing.T) { + root, err := filepath.EvalSymlinks(testenv.ShortTempDir(t)) + require.NoError(t, err) + for key, value := range migrationProxyOwnedEnv(root) { + t.Setenv(key, value) + } + repo := filepath.Join(root, "repo") + require.NoError(t, os.MkdirAll(filepath.Join(repo, "subdir"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(repo, "main.go"), []byte("package p\nfunc MigrationSocketProbe(){}\n"), 0o600)) + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", repo, "-c", "user.name=Migration Fixture", "-c", "user.email=migration@example.invalid", "-c", "core.hooksPath=" + os.DevNull}, args...)...) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", out) + } + git("init", "-q") + git("add", ".") + git("commit", "-q", "-m", "fixture") + linked := filepath.Join(root, "linked") + git("worktree", "add", "--detach", linked) + require.NoError(t, os.MkdirAll(filepath.Join(linked, "subdir"), 0o700)) + global := config.DefaultGlobalConfigPath() + require.NoError(t, os.MkdirAll(filepath.Dir(global), 0o700)) + require.NoError(t, os.WriteFile(global, []byte(fmt.Sprintf("repos:\n - path: %q\n name: migrating\n", repo)), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "repo.yaml"), []byte("semantic:\n enabled: false\n"), 0o600)) + path := filepath.Join(root, "store.sqlite") + seed, err := store_sqlite.Open(path) + require.NoError(t, err) + require.NoError(t, seed.Close()) + db, err := sql.Open("sqlite", path) + require.NoError(t, err) + _, err = db.Exec("PRAGMA user_version=39") + require.NoError(t, err) + require.NoError(t, db.Close()) + owner, _ := json.Marshal(map[string]int{"parent_pid": os.Getpid()}) + require.NoError(t, os.WriteFile(filepath.Join(root, "migration-owner.json"), owner, 0o600)) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + child := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestMigrationSocketOwnedHelper$", "-test.count=1") + child.Env = append(os.Environ(), "GORTEX_TEST_MIGRATION_SOCKET_ROOT="+root, "GORTEX_TELEMETRY=0") + child.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + var output bytes.Buffer + child.Stdout, child.Stderr = &output, &output + require.NoError(t, child.Start()) + finished := make(chan error, 1) + go func() { finished <- child.Wait() }() + joined := false + var clients []*daemon.Client + t.Cleanup(func() { + for _, client := range clients { + _ = client.Close() + } + _ = os.WriteFile(filepath.Join(root, "release-migration"), nil, 0o600) + if !joined { + select { + case <-finished: + joined = true + default: + } + } + if !joined { + _ = syscall.Kill(-child.Process.Pid, syscall.SIGKILL) + select { + case <-finished: + case <-time.After(5 * time.Second): + t.Error("owned migration child did not join") + } + } + }) + require.Eventually(t, func() bool { _, err := os.Stat(filepath.Join(root, "migration-entered")); return err == nil }, 15*time.Second, 5*time.Millisecond) + for _, cwd := range []string{repo, filepath.Join(repo, "subdir"), linked, filepath.Join(linked, "subdir")} { + started := time.Now() + client, err := daemon.DialToWithDeadline(filepath.Join(root, "s"), daemon.Handshake{Mode: daemon.ModeMCP, CWD: cwd, Tools: "full", ToolsMode: "hide"}, started.Add(time.Second)) + require.NoError(t, err, "socket must accept while the actual migration is blocked; cwd=%s", cwd) + clients = append(clients, client) + init := migrationSocketFrame(t, client, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"migration-window","version":"1"}}}`, 1) + require.NotContains(t, init, "error") + list := migrationSocketFrame(t, client, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`, 2) + require.NotContains(t, list, "error") + catalog, ok := list["result"].(map[string]any) + require.True(t, ok) + require.NotEmpty(t, catalog["tools"], "tools/list must publish a real catalog during migration") + require.Less(t, time.Since(started), time.Second, "metadata must remain responsive during migration") + response := migrationSocketFrame(t, client, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"MigrationSocketProbe","format":"json"}}}`, 3) + data := errorData(t, response) + require.Equal(t, "repo_loading", data["error_code"]) + require.Equal(t, true, data["retryable"]) + require.Equal(t, "migrating", data["phase"]) + } + require.NoError(t, os.WriteFile(filepath.Join(root, "release-migration"), nil, 0o600)) + // The same socket sessions must survive publication; no reconnect can hide + // a temporary catalog or a lost initialized-session state. + require.Eventually(t, func() bool { + response := migrationSocketFrame(t, clients[0], `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"MigrationSocketProbe","format":"json"}}}`, 4) + result, _ := response["result"].(map[string]any) + content, _ := result["content"].([]any) + for _, block := range content { + text, _ := block.(map[string]any)["text"].(string) + var payload struct { + Results []struct { + Name string `json:"name"` + ID string `json:"id"` + } `json:"results"` + } + if json.Unmarshal([]byte(text), &payload) == nil { + for _, node := range payload.Results { + if node.Name == "MigrationSocketProbe" && node.ID != "" { + return true + } + } + } + } + return false + }, 10*time.Second, 20*time.Millisecond) + control, err := daemon.DialToWithDeadline(filepath.Join(root, "s"), daemon.Handshake{Mode: daemon.ModeControl}, time.Now().Add(time.Second)) + require.NoError(t, err) + _, err = control.ControlWithTimeout(daemon.ControlShutdown, nil, 5*time.Second) + require.NoError(t, err) + require.NoError(t, control.Close()) + select { + case err := <-finished: + joined = true + require.NoError(t, err, "%s", output.String()) + case <-ctx.Done(): + t.Fatal("owned migration daemon did not stop") + } + require.NoFileExists(t, filepath.Join(root, "s")) +} diff --git a/cmd/gortex/daemon_orphan_cancel_test.go b/cmd/gortex/daemon_orphan_cancel_test.go new file mode 100644 index 000000000..03883d34a --- /dev/null +++ b/cmd/gortex/daemon_orphan_cancel_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" +) + +// Admission is held before real SQL work. This distinguishes lost cancellation +// from an expensive purge already in progress; no large fixture is needed. +type admittedOrphanPurge struct { + *store_sqlite.Store + entered chan struct{} + release chan struct{} + once sync.Once + legacy atomic.Int32 +} + +func (p *admittedOrphanPurge) admit() { p.once.Do(func() { close(p.entered) }); <-p.release } +func (p *admittedOrphanPurge) PurgeRepo(prefix string) error { + p.legacy.Add(1) + p.admit() + return p.Store.PurgeRepo(prefix) +} +func (p *admittedOrphanPurge) PurgeRepoContext(ctx context.Context, prefix string) error { + p.admit() + if err := ctx.Err(); err != nil { + return err + } + return p.Store.PurgeRepo(prefix) +} + +func TestWarmupOrphanPurgeRetainsCancellationAtAdmission(t *testing.T) { + root := t.TempDir() + store, err := store_sqlite.Open(filepath.Join(root, "tiny.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + const id = "gone/main.go::RetainedOnCancel" + store.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: "RetainedOnCancel", FilePath: "gone/main.go", RepoPrefix: "gone"}) + require.NotNil(t, store.GetNode(id)) + cm, err := config.NewConfigManager(filepath.Join(root, "config.yaml")) + require.NoError(t, err) + cm.Global().Repos = nil + registry := parser.NewRegistry() + mi := indexer.NewMultiIndexer(store, registry, nil, cm, zap.NewNop()) + t.Cleanup(func() { require.NoError(t, mi.Close(context.Background())) }) + purger := &admittedOrphanPurge{Store: store, entered: make(chan struct{}), release: make(chan struct{})} + state := &daemonState{graph: purger, multiIndexer: mi, configManager: cm} + task := newDaemonWarmup(context.Background()) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(purger.release) }) } + t.Cleanup(func() { task.cancel(); release(); task.wait() }) + require.True(t, task.start(func(ctx context.Context) { warmupDaemonStateContext(ctx, state, zap.NewNop(), nil, nil) })) + select { + case <-purger.entered: + case <-time.After(2 * time.Second): + t.Fatal("tiny orphan purge did not enter admission") + } + task.cancel() + release() + joined := make(chan struct{}) + go func() { task.wait(); close(joined) }() + select { + case <-joined: + case <-time.After(2 * time.Second): + t.Fatal("tiny canceled warmup did not join") + } + require.NotNil(t, store.GetNode(id), "canceled admission must not delete a real orphan row") + require.Zero(t, purger.legacy.Load(), "context-aware maintenance must receive the producer lifetime") +} diff --git a/cmd/gortex/daemon_repo_loading.go b/cmd/gortex/daemon_repo_loading.go index 4e3074229..79dcaf6b4 100644 --- a/cmd/gortex/daemon_repo_loading.go +++ b/cmd/gortex/daemon_repo_loading.go @@ -9,12 +9,16 @@ import ( // either listener. It performs no filesystem validation of those roots; warmup // does that later so a hung mount cannot delay serving other configured repos. func listenConfiguredDaemon(srv *daemon.Server, state *daemonState) error { + declareConfiguredDaemonRepos(state) + return srv.Listen() +} + +func declareConfiguredDaemonRepos(state *daemonState) { if state != nil && state.multiIndexer != nil && state.configManager != nil { if gc := state.configManager.Global(); gc != nil { state.multiIndexer.DeclareRepos(gc.ReposSnapshot()) } } - return srv.Listen() } // announceConfiguredRepos fills the declarations' filesystem identity before diff --git a/cmd/gortex/daemon_startup_progress_test.go b/cmd/gortex/daemon_startup_progress_test.go index 6b194ca9a..6d1b88ddd 100644 --- a/cmd/gortex/daemon_startup_progress_test.go +++ b/cmd/gortex/daemon_startup_progress_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "path/filepath" "strings" "testing" @@ -53,3 +54,49 @@ func TestDaemonStartupReporterMigrationFailureIsSanitized(t *testing.T) { t.Fatalf("raw sensitive error leaked: %q", st.StartupError) } } + +func TestDaemonStartupReporterReportsInsufficientUpgradeSpace(t *testing.T) { + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(t.TempDir(), "daemon.state.json")) + r := newDaemonStartupReporter(zap.NewNop()) + defer r.Stop() + store := filepath.Join(t.TempDir(), "store.sqlite") + refusal := &store_sqlite.InsufficientUpgradeSpaceError{Path: store, From: 21, To: 43, Need: 7_340_032_000, Available: 1_048_576} + wrapped := fmt.Errorf("build daemon state: %w", fmt.Errorf("open postgres://user:secret@example/private: %w", refusal)) + r.Fail(wrapped) + + st, ok := daemon.ReadRuntimeState() + if !ok || st.StartupPhase != daemon.StartupFailed { + t.Fatalf("failed state: %+v, ok=%v", st, ok) + } + for _, want := range []string{store, "v21", "v43", "7340032000 bytes", "1048576 bytes", "--backend-path"} { + if !strings.Contains(st.StartupError, want) { + t.Fatalf("refusal %q does not name %q", st.StartupError, want) + } + } + if strings.Contains(st.StartupError, "secret") || strings.Contains(st.StartupError, "private") { + t.Fatalf("the refusal must be rendered from its typed fields only: %q", st.StartupError) + } +} + +func TestDaemonStartupFailureNamesTheRecordedRefusal(t *testing.T) { + refusal := daemon.RuntimeState{PID: 42, StartupPhase: daemon.StartupFailed, StartupError: "not enough free disk space ..."} + if reason, ok := daemonStartupFailure(refusal, true, 42); !ok || reason != refusal.StartupError { + t.Fatalf("recorded refusal: %q %v", reason, ok) + } + if _, ok := daemonStartupFailure(refusal, true, 43); ok { + t.Fatal("another PID's record must not explain this child's exit") + } + if _, ok := daemonStartupFailure(refusal, false, 42); ok { + t.Fatal("a missing record explains nothing") + } + generic := refusal + generic.StartupError = boundedStartupError(errors.New("boom")) + if _, ok := daemonStartupFailure(generic, true, 42); ok { + t.Fatal("the generic pointer to the log adds nothing to the exit message") + } + migrating := refusal + migrating.StartupPhase = daemon.StartupMigrating + if _, ok := daemonStartupFailure(migrating, true, 42); ok { + t.Fatal("only a failed phase carries a reason") + } +} diff --git a/cmd/gortex/daemon_state.go b/cmd/gortex/daemon_state.go index 785de26c7..2eaee6aef 100644 --- a/cmd/gortex/daemon_state.go +++ b/cmd/gortex/daemon_state.go @@ -89,6 +89,10 @@ type daemonState struct { migratedStore bool } +// buildDaemonStateForStart keeps the foreground startup boundary injectable for +// owned-process migration/listener regressions. Production uses the same builder. +var buildDaemonStateForStart = buildDaemonState + // buildDaemonState builds the daemon's stack through the shared // serverstack constructor and returns the long-lived daemonState the // warmup loop and controller share. @@ -256,6 +260,16 @@ func (r *daemonStartupReporter) ObserveMigration(progress store_sqlite.Migration zap.Duration("elapsed", progress.Elapsed), zap.Error(progress.Error)) r.state.StartupPhase = daemon.StartupFailed r.state.StartupError = boundedStartupError(progress.Error) + case store_sqlite.MigrationBackupCreated: + r.logger.Info("daemon: backed up the store before its schema upgrade", + zap.Int("from_version", progress.Version), zap.String("backup", progress.Detail), + zap.Duration("elapsed", progress.Elapsed)) + case store_sqlite.MigrationBackupKept: + r.logger.Info("daemon: kept the existing pre-upgrade store backup", + zap.Int("from_version", progress.Version), zap.String("backup", progress.Detail)) + case store_sqlite.MigrationBackupSkipped: + r.logger.Warn("daemon: upgrading the store schema without a backup", + zap.Int("from_version", progress.Version), zap.String("reason", progress.Detail)) } r.publishLocked() } @@ -304,14 +318,27 @@ func (r *daemonStartupReporter) publishLocked() { } } +// startupFailedMessage is the recorded reason for every startup failure that +// has no typed, safe rendering of its own. +const startupFailedMessage = "startup failed; check the daemon log" + func boundedStartupError(err error) string { if err == nil { return "" } + // A refused store upgrade is the one failure the user has to act on + // before a restart can succeed, so it is recorded with its numbers. It is + // rendered from the typed fields only, never from the wrapped chain; the + // path is this daemon's own resolved store, the same value BackendPath + // records once it serves. + var short *store_sqlite.InsufficientUpgradeSpaceError + if errors.As(err, &short) { + return short.Error() + " (--backend-path starts the daemon on another volume)" + } // Runtime state is consumed out of band and must not become a side // channel for SQL text, filesystem paths, DSNs, or credentials embedded in // wrapped errors. The daemon log retains the full structured error. - return "startup failed; check the daemon log" + return startupFailedMessage } // warmupTimings collects the per-phase costs of one warmupDaemonState run so @@ -599,7 +626,13 @@ func warmupDaemonStateContext(ctx context.Context, state *daemonState, logger *z return nil, timings } finishPurge := startWarmupStep(logger, "orphan_prefix_purge", zap.String("repo", prefix)) - if err := purger.PurgeRepo(prefix); err != nil { + purge := purger.PurgeRepo + if scoped, ok := state.graph.(interface { + PurgeRepoContext(context.Context, string) error + }); ok { + purge = func(prefix string) error { return scoped.PurgeRepoContext(ctx, prefix) } + } + if err := purge(prefix); err != nil { purgeFailures++ finishPurge(zap.String("outcome", "error"), zap.Error(err)) logger.Warn("daemon: purging orphaned repo prefix failed", @@ -1318,8 +1351,11 @@ func warmupDaemonStateContext(ctx context.Context, state *daemonState, logger *z logger.Info("daemon: reconciled post-batch contract edges", zap.Int("contract_edges", reconciled)) case warmGlobalResolveFull: - if err := state.multiIndexer.RunGlobalResolveContext(ctx); err != nil && ctx.Err() == nil { - logger.Warn("daemon: warmup post-batch resolve incomplete", zap.Error(err)) + if err := state.multiIndexer.RunGlobalResolveContext(ctx); err != nil { + resolveOK = false + if ctx.Err() == nil { + logger.Warn("daemon: warmup post-batch resolve incomplete", zap.Error(err)) + } } logger.Info("daemon: post-batch workspace eligibility change forced full cross-repo catch-up") } @@ -1365,6 +1401,9 @@ func warmupDaemonStateContext(ctx context.Context, state *daemonState, logger *z if ctx.Err() != nil { return nil, timings } + if resolveOK { + state.multiIndexer.CompleteStartupRelations() + } state.basePublisher.BeginDraining() finishWatchers := startWarmupStep(logger, "watcher_start") diff --git a/cmd/gortex/daemon_store_backup_test.go b/cmd/gortex/daemon_store_backup_test.go new file mode 100644 index 000000000..478a2850f --- /dev/null +++ b/cmd/gortex/daemon_store_backup_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +func TestDaemonStartupPrunesExpiredStoreBackups(t *testing.T) { + startupPublicationEnv(t) + store := daemonBackendPath + now := time.Now() + aged := func(path string, age time.Duration) string { + t.Helper() + if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, now.Add(-age), now.Add(-age)); err != nil { + t.Fatal(err) + } + return path + } + expired := aged(store+".v21.backup", 15*24*time.Hour) + recent := aged(store+".v40.backup", 5*24*time.Hour) + partial := aged(store+".v43.backup.partial", time.Minute) + unrelated := aged(store+".bak", 30*24*time.Hour) + + state, err := buildDaemonState(zap.NewNop()) + if err != nil { + t.Fatalf("buildDaemonState: %v", err) + } + t.Cleanup(func() { + if state.shared != nil { + _ = state.shared.Close() + } + }) + + for _, gone := range []string{expired, partial} { + if _, err := os.Stat(gone); !os.IsNotExist(err) { + t.Fatalf("daemon start left %s behind (stat err %v)", filepath.Base(gone), err) + } + } + for _, kept := range []string{recent, unrelated} { + if _, err := os.Stat(kept); err != nil { + t.Fatalf("daemon start removed %s: %v", filepath.Base(kept), err) + } + } +} + +func TestDaemonStartupReporterLogsBackupDecisions(t *testing.T) { + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(t.TempDir(), "daemon.state.json")) + core, logs := observer.New(zapcore.InfoLevel) + r := newDaemonStartupReporter(zap.New(core)) + defer r.Stop() + + r.ObserveMigration(store_sqlite.MigrationProgress{Version: 21, Phase: store_sqlite.MigrationBackupCreated, Detail: "/stores/store.sqlite.v21.backup"}) + r.ObserveMigration(store_sqlite.MigrationProgress{Version: 21, Phase: store_sqlite.MigrationBackupSkipped, Detail: "5 bytes available cover the upgrade"}) + + created := logs.FilterField(zap.String("backup", "/stores/store.sqlite.v21.backup")).All() + if len(created) != 1 || created[0].Level != zapcore.InfoLevel { + t.Fatalf("created backup log: %+v", created) + } + skipped := logs.FilterField(zap.String("reason", "5 bytes available cover the upgrade")).All() + if len(skipped) != 1 || skipped[0].Level != zapcore.WarnLevel { + t.Fatalf("skipped backup log must say why: %+v", skipped) + } + st, ok := daemon.ReadRuntimeState() + if !ok || st.StartupPhase != daemon.StartupOpeningStore || st.StartupError != "" { + t.Fatalf("a backup decision is not a startup phase: %+v ok=%v", st, ok) + } +} diff --git a/cmd/gortex/ensure_daemon.go b/cmd/gortex/ensure_daemon.go index a62dc011e..e0311bdc1 100644 --- a/cmd/gortex/ensure_daemon.go +++ b/cmd/gortex/ensure_daemon.go @@ -13,6 +13,7 @@ import ( "github.com/gofrs/flock" "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/platform" ) // daemonDecision is the resolved auto-start outcome. @@ -128,20 +129,70 @@ func waitSpawnLockInterval(ctx context.Context, pause time.Duration) error { } } -func daemonStartupHeartbeatFresh(now time.Time) bool { - state, ok := daemon.ReadRuntimeState() - return ok && state.StartupProgressFresh(now, spawnProgressFreshness) +// daemonStartupHeartbeatWaiter belongs to one contended spawn-lock wait. A +// fresh pre-socket record may transition to Serving before endpoint visibility +// reaches this caller. Honor that same process's handoff for one bounded lease; +// an already-serving daemon must not keep a replacement wait alive forever. +func daemonStartupHeartbeatWaiter() func(time.Time) bool { + return startupHeartbeatWaiterWithOwnerProof(startupRuntimeOwnerMatches) +} + +func startupRuntimeOwnerMatches(state daemon.RuntimeState) bool { + if state.ProcessBirthToken == "" { + return false + } + owner, err := platform.CaptureProcessIdentity(state.PID) + if err != nil { + return false + } + token, err := owner.Token() + if err != nil || token != state.ProcessBirthToken { + return false + } + running, err := owner.Running() + return err == nil && running +} + +func startupHeartbeatWaiterWithOwnerProof(ownerMatches func(daemon.RuntimeState) bool) func(time.Time) bool { + var ownerPID int + var ownerBirth string + var handoffUntil time.Time + return func(now time.Time) bool { + state, ok := daemon.ReadRuntimeState() + if !ok { + return false + } + if state.StartupProgressFresh(now, spawnProgressFreshness) { + ownerPID, ownerBirth = 0, "" + handoffUntil = time.Time{} + // Preserve legacy pre-socket waiting, but grant the new Serving lease + // only when the recorded incarnation matches the actual live process. + if ownerMatches(state) { + ownerPID, ownerBirth = state.PID, state.ProcessBirthToken + handoffUntil = now.Add(spawnProgressFreshness) + } + return true + } + if state.StartupPhase != daemon.StartupServing || ownerPID <= 0 || state.PID != ownerPID || state.ProcessBirthToken != ownerBirth || !now.Before(handoffUntil) || !ownerMatches(state) { + return false + } + // This is a local copy only: reuse the existing timestamp/skew checks + // without broadening pre-socket progress semantics for other callers. + state.StartupPhase = daemon.StartupOpeningStore + return state.StartupProgressFresh(now, spawnProgressFreshness) + } } // Injectable seams so the race/fallback/spawn-failure branches are // testable without a real daemon. var ( - isDaemonRunning = daemon.IsRunning - spawnDaemon = spawnBareDaemon - stopIntentActive = daemon.StopIntentActive - runningDaemonInfo = dialRunningDaemonIdentity - binaryVersion = canonicalVersion - stopStaleDaemon = stopDaemonForHandoff + waitForDaemonSpawnLock = waitForSpawnLock + isDaemonRunning = daemon.IsRunning + spawnDaemon = spawnBareDaemon + stopIntentActive = daemon.StopIntentActive + runningDaemonInfo = dialRunningDaemonIdentity + binaryVersion = canonicalVersion + stopStaleDaemon = stopDaemonForHandoff ) // spawnBareDaemon is the autostart default for spawnDaemon. Autostart has no @@ -204,9 +255,9 @@ func ensureDaemonReadyWithError(autostart bool) (daemonDecision, daemonIdentity, _ = os.MkdirAll(filepath.Dir(lockPath), 0o700) lock := flock.New(lockPath) - locked, err := waitForSpawnLock(context.Background(), lock, spawnLockWaitOptions{ + locked, err := waitForDaemonSpawnLock(context.Background(), lock, spawnLockWaitOptions{ daemonRunning: isDaemonRunning, - startupProgress: daemonStartupHeartbeatFresh, + startupProgress: daemonStartupHeartbeatWaiter(), }) if err != nil || !locked { // Couldn't acquire the lock in time. The winner may have brought @@ -254,8 +305,8 @@ func replaceStaleDaemon(observed daemonIdentity) (daemonDecision, daemonIdentity _ = os.MkdirAll(filepath.Dir(lockPath), 0o700) lock := flock.New(lockPath) - locked, err := waitForSpawnLock(context.Background(), lock, spawnLockWaitOptions{ - startupProgress: daemonStartupHeartbeatFresh, + locked, err := waitForDaemonSpawnLock(context.Background(), lock, spawnLockWaitOptions{ + startupProgress: daemonStartupHeartbeatWaiter(), }) if err != nil || !locked { if err == nil { diff --git a/cmd/gortex/ensure_daemon_handoff_progress_test.go b/cmd/gortex/ensure_daemon_handoff_progress_test.go new file mode 100644 index 000000000..05554493b --- /dev/null +++ b/cmd/gortex/ensure_daemon_handoff_progress_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/daemon" +) + +func TestStartupHandoffRequiresAnObservedMatchingOwner(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + t.Setenv("GORTEX_DAEMON_STATEFILE", path) + now := time.Now() + write := func(phase daemon.StartupPhase, updated time.Time) { + writeHandoffStateFixture(t, phase, updated) + } + waiter := daemonStartupHeartbeatWaiter() + write(daemon.StartupServing, now) + require.False(t, waiter(now), "a daemon already serving is not evidence of this wait's startup") + write(daemon.StartupMigrating, now) + require.True(t, waiter(now)) + write(daemon.StartupServing, now) + require.True(t, waiter(now.Add(time.Millisecond)), "same owner can finish its non-atomic handoff") + require.False(t, daemonStartupHeartbeatWaiter()(now.Add(time.Millisecond)), "observation is local to one wait") + var state daemon.RuntimeState + body, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &state)) + state.ProcessBirthToken = "different-process-incarnation" + body, err = json.Marshal(state) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, body, 0o600)) + require.False(t, waiter(now.Add(time.Millisecond)), "a reused PID cannot inherit the handoff lease") +} + +func TestStartupHandoffDoesNotRenewFromPeriodicServingHeartbeats(t *testing.T) { + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(t.TempDir(), "state.json")) + now := time.Now() + waiter := daemonStartupHeartbeatWaiter() + writeHandoffStateFixture(t, daemon.StartupOpeningStore, now) + require.True(t, waiter(now)) + later := now.Add(spawnProgressFreshness + time.Millisecond) + writeHandoffStateFixture(t, daemon.StartupServing, later) + require.False(t, waiter(later), "fresh periodic Serving writes cannot extend the fixed handoff lease") +} + +func TestStartupHandoffRejectsStaleFailedAndFutureRecords(t *testing.T) { + for _, name := range []string{"stale", "failed", "future"} { + t.Run(name, func(t *testing.T) { + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(t.TempDir(), "state.json")) + now := time.Now() + waiter := daemonStartupHeartbeatWaiter() + writeHandoffStateFixture(t, daemon.StartupMigrating, now) + require.True(t, waiter(now)) + state := daemon.RuntimeState{StartupPhase: daemon.StartupServing, StartupUpdatedAt: now.UnixMilli()} + switch name { + case "stale": + state.StartupUpdatedAt = now.Add(-2 * spawnProgressFreshness).UnixMilli() + case "failed": + state.StartupPhase = daemon.StartupFailed + case "future": + state.StartupUpdatedAt = now.Add(2 * spawnProgressFreshness).UnixMilli() + } + writeHandoffStateFixture(t, state.StartupPhase, time.UnixMilli(state.StartupUpdatedAt)) + require.False(t, waiter(now.Add(time.Millisecond))) + }) + } +} + +// The real publisher deliberately stamps real time. Alter only this owned +// synthetic record after publication to exercise stale/future clock inputs. +func writeHandoffStateFixture(t *testing.T, phase daemon.StartupPhase, updated time.Time) { + t.Helper() + require.NoError(t, daemon.WriteRuntimeState(daemon.RuntimeState{StartupPhase: phase})) + path := daemon.RuntimeStatePath() + body, err := os.ReadFile(path) + require.NoError(t, err) + var state daemon.RuntimeState + require.NoError(t, json.Unmarshal(body, &state)) + state.StartupUpdatedAt = updated.UnixMilli() + body, err = json.Marshal(state) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, body, 0o600)) +} + +func TestStartupHandoffRejectsUnchangedStaleOwnerToken(t *testing.T) { + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(t.TempDir(), "state.json")) + now := time.Now() + writeHandoffStateFixture(t, daemon.StartupMigrating, now) + body, err := os.ReadFile(daemon.RuntimeStatePath()) + require.NoError(t, err) + var state daemon.RuntimeState + require.NoError(t, json.Unmarshal(body, &state)) + // Model an old incarnation's unchanged, well-formed token at a PID that + // currently names a live different incarnation. The real native identity + // disagrees, although both reads of this owned record agree with each other. + parts := strings.Split(state.ProcessBirthToken, ":") + require.Len(t, parts, 5) + first := "f" + if strings.HasPrefix(parts[2], first) { + first = "e" + } + parts[2] = first + parts[2][1:] + state.ProcessBirthToken = strings.Join(parts, ":") + recordedToken := state.ProcessBirthToken + body, err = json.Marshal(state) + require.NoError(t, err) + require.NoError(t, os.WriteFile(daemon.RuntimeStatePath(), body, 0o600)) + waiter := daemonStartupHeartbeatWaiter() + require.True(t, waiter(now), "legacy pre-socket freshness behavior stays unchanged") + state.StartupPhase = daemon.StartupServing + body, err = json.Marshal(state) + require.NoError(t, err) + require.NoError(t, os.WriteFile(daemon.RuntimeStatePath(), body, 0o600)) + require.Equal(t, recordedToken, state.ProcessBirthToken, "the stale owner token never changes") + require.False(t, waiter(now.Add(time.Millisecond)), "file-to-file equality cannot authorize a native-incarnation handoff") +} + +func TestStartupHandoffLegacyPresocketDoesNotGrantServingLease(t *testing.T) { + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(t.TempDir(), "state.json")) + now := time.Now() + writeHandoffStateFixture(t, daemon.StartupOpeningStore, now) + body, err := os.ReadFile(daemon.RuntimeStatePath()) + require.NoError(t, err) + var state daemon.RuntimeState + require.NoError(t, json.Unmarshal(body, &state)) + state.ProcessBirthToken = "" + body, err = json.Marshal(state) + require.NoError(t, err) + require.NoError(t, os.WriteFile(daemon.RuntimeStatePath(), body, 0o600)) + waiter := daemonStartupHeartbeatWaiter() + require.True(t, waiter(now)) + state.StartupPhase = daemon.StartupServing + body, err = json.Marshal(state) + require.NoError(t, err) + require.NoError(t, os.WriteFile(daemon.RuntimeStatePath(), body, 0o600)) + require.False(t, waiter(now.Add(time.Millisecond)), "a legacy record cannot establish the new incarnation-bound Serving lease") +} + +func TestStartupHandoffRechecksNativeProofWhenConsumed(t *testing.T) { + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(t.TempDir(), "state.json")) + now := time.Now() + proofAvailable := true + waiter := startupHeartbeatWaiterWithOwnerProof(func(state daemon.RuntimeState) bool { return proofAvailable && startupRuntimeOwnerMatches(state) }) + writeHandoffStateFixture(t, daemon.StartupMigrating, now) + require.True(t, waiter(now)) + before, ok := daemon.ReadRuntimeState() + require.True(t, ok) + // Native metadata may become inaccessible or identify another incarnation + // after grant. Keeping the record unchanged cannot substitute for re-proof. + proofAvailable = false + writeHandoffStateFixture(t, daemon.StartupServing, now) + after, ok := daemon.ReadRuntimeState() + require.True(t, ok) + require.Equal(t, before.ProcessBirthToken, after.ProcessBirthToken) + require.False(t, waiter(now.Add(time.Millisecond))) +} diff --git a/cmd/gortex/eval_recall.go b/cmd/gortex/eval_recall.go index 73e119ab0..5a65ed3d1 100644 --- a/cmd/gortex/eval_recall.go +++ b/cmd/gortex/eval_recall.go @@ -244,7 +244,7 @@ func runEvalRecall(_ *cobra.Command, _ []string) error { if selected["winnow"] { eng := query.NewEngine(g) eng.SetSearch(textBackend) - srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), cfg.Guards.Rules) + srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), cfg.Guards.Rules, gortexmcp.MultiRepoOptions{EditAckMode: cfg.Edit.Ack}) srv.SetArchitecture(cfg.Architecture) srv.SetEventRules(cfg.Events.Rules) srv.SetArtifacts(cfg.Artifacts) diff --git a/cmd/gortex/eval_server.go b/cmd/gortex/eval_server.go index 8618a878f..cdc260230 100644 --- a/cmd/gortex/eval_server.go +++ b/cmd/gortex/eval_server.go @@ -68,7 +68,7 @@ func runEvalServer(cmd *cobra.Command, args []string) error { eng := query.NewEngine(g) eng.SetSearch(idx.Search()) gortexmcp.Version = version - srv := gortexmcp.NewServer(eng, g, idx, nil, logger, cfg.Guards.Rules) + srv := gortexmcp.NewServer(eng, g, idx, nil, logger, cfg.Guards.Rules, gortexmcp.MultiRepoOptions{EditAckMode: cfg.Edit.Ack}) defer srv.DrainBackground() srv.SetArchitecture(cfg.Architecture) srv.SetEventRules(cfg.Events.Rules) diff --git a/cmd/gortex/migration_window_proxy_test.go b/cmd/gortex/migration_window_proxy_test.go index 3277f50a1..8832ec2a2 100644 --- a/cmd/gortex/migration_window_proxy_test.go +++ b/cmd/gortex/migration_window_proxy_test.go @@ -89,9 +89,50 @@ func TestMigrationWindowMCPProxyHelper(t *testing.T) { require.Equal(t, input.SpawnLock, daemon.SpawnLockPath()) require.Equal(t, input.GlobalPath, config.DefaultGlobalConfigPath()) witnessDir := input.Root + var trace []map[string]any + servingRecorded := false + defer func() { + body, err := json.Marshal(trace) + if err == nil { + fmt.Fprintf(os.Stderr, "MIGRATION_WAIT_TRACE %s\n", body) + } + }() + waitForDaemonSpawnLock = func(ctx context.Context, lock spawnLockTryer, opts spawnLockWaitOptions) (bool, error) { + progress := opts.startupProgress + opts.startupProgress = func(now time.Time) bool { + fresh := progress(now) + state, ok := daemon.ReadRuntimeState() + trace = append(trace, map[string]any{"kind": "startup_progress", "at": time.Now(), "now": now, "fresh": fresh, "state_read": ok, "state": state}) + if ok && state.StartupPhase == daemon.StartupServing && !servingRecorded { + event := map[string]any{"fresh": fresh, "state": state, "lock_contended": true, "at": time.Now()} + body, err := json.Marshal(event) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(witnessDir, "serving-probe.json"), body, 0o600)) + servingRecorded = true + } + + return fresh + } + locked, err := waitForSpawnLock(ctx, lock, opts) + event := map[string]any{"kind": "wait_return", "at": time.Now(), "locked": locked} + if err != nil { + event["error"] = err.Error() + } + trace = append(trace, event) + body, marshalErr := json.Marshal(trace) + require.NoError(t, marshalErr) + require.NoError(t, os.WriteFile(filepath.Join(witnessDir, "wait-return.json"), body, 0o600)) + return locked, err + } checks := 0 isDaemonRunning = func() bool { - running := daemon.IsRunning() + probeErr := daemon.ProbeAvailability() + running := probeErr == nil + event := map[string]any{"kind": "availability", "at": time.Now(), "running": running} + if probeErr != nil { + event["error"] = probeErr.Error() + } + trace = append(trace, event) checks++ if !running && checks >= 2 { probe := flock.New(daemon.SpawnLockPath()) @@ -129,7 +170,10 @@ func TestMigrationWindowMCPProxyHelper(t *testing.T) { // proves renewal from a fresh PID-bound startup record, not the reporter's // two-second periodic heartbeat. Historical schema-v21 workload acceptance is // separate; this fixture deliberately replays the idempotent v40 step. -func TestMCPWaitsThroughMigrationWindow(t *testing.T) { +func TestMCPWaitsThroughMigrationWindow(t *testing.T) { testMCPStartupWindow(t, false) } +func TestMCPWaitsThroughServingHandoff(t *testing.T) { testMCPStartupWindow(t, true) } + +func testMCPStartupWindow(t *testing.T, holdServing bool) { base := testenv.ShortTempDir(t) // Keep this migration regression's config and actual child CWD under the // same physical spelling. Alias admission is a separate unresolved case; @@ -246,10 +290,19 @@ func TestMCPWaitsThroughMigrationWindow(t *testing.T) { if !reaped { select { case <-exited: + reaped = true case <-time.After(2 * time.Second): t.Error("owned MCP helper did not reap") } } + if t.Failed() { + if reaped { + t.Logf("owned MCP helper stderr:\n%s", stderr.String()) + } + if trace, err := os.ReadFile(filepath.Join(base, "wait-return.json")); err == nil { + t.Logf("actual startup wait decisions:\n%s", trace) + } + } }) frames := streamProxyFrames(ctx, stdout) _, err = fmt.Fprintln(stdin, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"migration-probe","version":"1"}}}`) @@ -284,6 +337,13 @@ func TestMCPWaitsThroughMigrationWindow(t *testing.T) { t.Fatalf("MCP produced output before migration release: %s (%v)", frame.frame, frame.err) default: } + // An embedded build can delay its first frame. Prove the real startup wait + // itself has not returned; absence of stdout alone cannot establish this. + if returned, readErr := os.ReadFile(filepath.Join(base, "wait-return.json")); readErr == nil { + t.Fatalf("startup wait returned while the actual migration and spawn lock remained held: %s", returned) + } else { + require.ErrorIs(t, readErr, os.ErrNotExist) + } releaseOnce.Do(func() { close(release) }) select { case result = <-built: @@ -292,6 +352,35 @@ func TestMCPWaitsThroughMigrationWindow(t *testing.T) { t.Fatal("migration did not complete after release") } require.NoError(t, result.err) + if holdServing { + // Metadata and endpoint visibility are separate operations. Keep the real + // spawn lock held after the real migration while observing the Serving + // record before an endpoint exists; an expired idle budget must not pick + // embedded mode during this fresh PID-bound handoff. + reporter.Serving(daemonBackendPath) + var observed struct { + Fresh bool `json:"fresh"` + LockContended bool `json:"lock_contended"` + State daemon.RuntimeState `json:"state"` + } + require.Eventually(t, func() bool { + body, err := os.ReadFile(filepath.Join(base, "serving-probe.json")) + return err == nil && json.Unmarshal(body, &observed) == nil + }, 5*time.Second, 5*time.Millisecond) + require.True(t, observed.LockContended) + require.Equal(t, os.Getpid(), observed.State.PID) + require.Equal(t, daemon.StartupServing, observed.State.StartupPhase) + select { + case frame := <-frames: + t.Fatalf("premature output during Serving handoff: %s (%v)", frame.frame, frame.err) + case <-time.After(3 * migrationProxyTestInactivity): + } + if returned, err := os.ReadFile(filepath.Join(base, "wait-return.json")); err == nil { + t.Fatalf("startup wait returned before handoff endpoint publication: %s", returned) + } else { + require.ErrorIs(t, err, os.ErrNotExist) + } + } srv := daemon.New(os.Getenv("GORTEX_DAEMON_SOCKET"), "test", zap.NewNop()) srv.MCPDispatcher = newMCPDispatcher(result.state.mcpServer, result.state.multiIndexer, zap.NewNop()) require.NoError(t, listenConfiguredDaemon(srv, result.state)) diff --git a/cmd/gortex/query.go b/cmd/gortex/query.go index 8ddb1becc..9b5441b93 100644 --- a/cmd/gortex/query.go +++ b/cmd/gortex/query.go @@ -53,6 +53,7 @@ func requireDaemonTool(repoPath, tool string, args map[string]any) (json.RawMess // surface without changing daemon-global configuration. It shares the normal // daemon-required and repo-not-tracked error behavior. func requireDaemonToolWithSurface(repoPath, tool string, args map[string]any, tools, toolsMode string) (json.RawMessage, error) { + args = applyCLIRequireFresh(args) exec, err := resolveExecutorWithToolSurface(repoPath, tools, toolsMode) if err != nil { if errors.Is(err, ErrNoExecutor) { @@ -62,6 +63,11 @@ func requireDaemonToolWithSurface(repoPath, tool string, args map[string]any, to } defer exec.Close() out, err := callToolWaitingForLoad(exec, tool, args, effectiveLoadingWait()) + if freshnessErr := checkCLIFreshness(rootCmd, out, err); freshnessErr != nil { + if _, coded := freshnessErr.(*exitCodeError); coded { + return nil, freshnessErr + } + } if err != nil { if errors.Is(err, ErrRepoNotTracked) { return nil, daemonRequiredErr(repoPath) diff --git a/cmd/gortex/repos_cmd.go b/cmd/gortex/repos_cmd.go index cb313afa1..b7b6c1e02 100644 --- a/cmd/gortex/repos_cmd.go +++ b/cmd/gortex/repos_cmd.go @@ -3,10 +3,8 @@ package main import ( "encoding/json" "fmt" - "path/filepath" "sort" "strconv" - "strings" "time" "github.com/jedib0t/go-pretty/v6/table" @@ -17,7 +15,6 @@ import ( "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/graph/store_sqlite" - "github.com/zzet/gortex/internal/platform" "github.com/zzet/gortex/internal/progress" "github.com/zzet/gortex/internal/tui" ) @@ -209,13 +206,7 @@ func loadRepoIndexStates() (map[string]graph.RepoIndexState, error) { // 3. The platform default, which is where a daemon started without the flag // put its store anyway. func resolveReposBackendPath() string { - if path := strings.TrimSpace(reposBackendPath); path != "" { - return path - } - if st, ok := daemon.ReadRuntimeState(); ok && st.BackendPath != "" { - return st.BackendPath - } - return filepath.Join(platform.StoreDir(), "store.sqlite") + return resolveStoreBackendPath(reposBackendPath) } // describeRepo resolves one RepoEntry into a repoStatus by reading the diff --git a/cmd/gortex/root.go b/cmd/gortex/root.go index d21856d43..1e31f0dd5 100644 --- a/cmd/gortex/root.go +++ b/cmd/gortex/root.go @@ -167,6 +167,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default .gortex.yaml)") rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "log level: debug|info|warn|error") rootCmd.PersistentFlags().BoolVar(&noProgress, "no-progress", false, "disable animated progress output (also honored: NO_COLOR, TERM=dumb, CI, non-TTY stderr)") + rootCmd.PersistentFlags().BoolVar(&cliRequireFresh, "require-fresh", false, "wait for graph freshness; exit 4 if the requested tier remains pending or failed") } func newLoggerConfig() zap.Config { @@ -250,6 +251,7 @@ func assignCommandGroups() { "mcp": "serve", "daemon": "engine", "track": "engine", "untrack": "engine", "repos": "engine", "status": "engine", "proxy": "engine", "workspace": "engine", + "store": "engine", "query": "query", "context": "query", "audit": "query", "wiki": "query", "docs": "query", "export": "query", "wakeup": "query", "prs": "query", "review": "query", "call": "query", "tools": "query", diff --git a/cmd/gortex/store_cmd.go b/cmd/gortex/store_cmd.go new file mode 100644 index 000000000..89bdff55b --- /dev/null +++ b/cmd/gortex/store_cmd.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/platform" +) + +// storeBackendPath is the graph store `gortex store` subcommands act on. +// Empty defers to resolveStoreBackendPath. Set by --backend-path. +var storeBackendPath string + +var storeCmd = &cobra.Command{ + Use: "store", + Short: "Maintain the on-disk graph store", +} + +var storeCleanupBackupCmd = &cobra.Command{ + Use: "cleanup-backup", + Short: "Remove the store backups taken before schema upgrades", + Long: `Removes the backups the daemon takes of the graph store before it upgrades +the store's schema. Each sits next to the store as +.v.backup and is the rollback path to the release +that wrote it; the daemon also removes backups older than 14 days when it +starts. + +The store is located in this order: --backend-path if given, else the store a +running daemon recorded at startup, else ~/.gortex/store/store.sqlite. +A partial copy (*.backup.partial) is left alone, since a starting daemon may +be writing it; the next daemon start removes an abandoned one.`, + Args: cobra.NoArgs, + RunE: runStoreCleanupBackup, +} + +func init() { + storeCleanupBackupCmd.Flags().StringVar(&storeBackendPath, "backend-path", "", + "remove the backups of this store file (default: the running daemon's recorded store, else ~/.gortex/store/store.sqlite)") + storeCmd.AddCommand(storeCleanupBackupCmd) + rootCmd.AddCommand(storeCmd) +} + +func runStoreCleanupBackup(cmd *cobra.Command, _ []string) error { + storePath := resolveStoreBackendPath(storeBackendPath) + removed, err := store_sqlite.RemoveUpgradeBackups(storePath) + out := cmd.OutOrStdout() + for _, backup := range removed { + fmt.Fprintf(out, "removed %s (schema v%d, %s)\n", backup.Path, backup.Version, formatBytes(uint64(backup.Size))) + } + if err != nil { + return fmt.Errorf("remove store backups of %s: %w", storePath, err) + } + if len(removed) == 0 { + fmt.Fprintf(out, "no store backups next to %s\n", storePath) + } + return nil +} + +// resolveStoreBackendPath locates the graph store an out-of-band command +// reads or maintains: the explicit path if given, else the store a running +// daemon recorded at startup (so a daemon started with --backend-path is +// followed without repeating the flag), else the platform default. +func resolveStoreBackendPath(explicit string) string { + if path := strings.TrimSpace(explicit); path != "" { + return path + } + if st, ok := daemon.ReadRuntimeState(); ok && st.BackendPath != "" { + return st.BackendPath + } + return filepath.Join(platform.StoreDir(), "store.sqlite") +} diff --git a/cmd/gortex/store_cmd_test.go b/cmd/gortex/store_cmd_test.go new file mode 100644 index 000000000..de1a6c990 --- /dev/null +++ b/cmd/gortex/store_cmd_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +func storeCleanupBackupEnv(t *testing.T) string { + t.Helper() + dir := t.TempDir() + // No daemon record may route the command away from the isolated store. + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(dir, "daemon.state.json")) + store := filepath.Join(dir, "store.sqlite") + prev := storeBackendPath + storeBackendPath = store + t.Cleanup(func() { storeBackendPath = prev }) + require.NoError(t, os.WriteFile(store, []byte("live store"), 0o600)) + return store +} + +func TestStoreCleanupBackupRemovesUpgradeBackups(t *testing.T) { + store := storeCleanupBackupEnv(t) + v21 := store_sqlite.UpgradeBackupPath(store, 21) + v40 := store_sqlite.UpgradeBackupPath(store, 40) + partial := store_sqlite.UpgradeBackupPath(store, 43) + ".partial" + for _, p := range []string{v21, v40, partial} { + require.NoError(t, os.WriteFile(p, []byte(filepath.Base(p)), 0o600)) + } + + cmd, buf := newReposCmd() + require.NoError(t, runStoreCleanupBackup(cmd, nil)) + + require.NoFileExists(t, v21) + require.NoFileExists(t, v40) + require.FileExists(t, partial, "a partial copy may belong to a starting daemon") + require.FileExists(t, store, "the live store is never touched") + out := buf.String() + require.Contains(t, out, v21) + require.Contains(t, out, v40) + require.Equal(t, 2, strings.Count(out, "removed "), out) +} + +func TestStoreCleanupBackupReportsNothingToRemove(t *testing.T) { + store := storeCleanupBackupEnv(t) + cmd, buf := newReposCmd() + require.NoError(t, runStoreCleanupBackup(cmd, nil)) + require.Contains(t, buf.String(), "no store backups next to "+store) +} + +func TestStoreCleanupBackupFollowsTheDaemonsStore(t *testing.T) { + storeCleanupBackupEnv(t) + storeBackendPath = "" + daemonStore := filepath.Join(t.TempDir(), "daemon-store.sqlite") + require.NoError(t, daemon.WriteRuntimeState(daemon.RuntimeState{BackendPath: daemonStore})) + require.Equal(t, daemonStore, resolveStoreBackendPath(""), "a running daemon's recorded store is the default") + require.Equal(t, "/explicit/store.sqlite", resolveStoreBackendPath(" /explicit/store.sqlite "), "--backend-path wins") + + found, _, err := rootCmd.Find([]string{"store", "cleanup-backup"}) + require.NoError(t, err) + require.Equal(t, storeCleanupBackupCmd, found) + require.NotNil(t, found.Flags().Lookup("backend-path")) +} diff --git a/cmd/gortex/testdata/agent-render/antigravity.txt b/cmd/gortex/testdata/agent-render/antigravity.txt index 84fe0ba28..30ce49db9 100644 --- a/cmd/gortex/testdata/agent-render/antigravity.txt +++ b/cmd/gortex/testdata/agent-render/antigravity.txt @@ -24,11 +24,11 @@ Common calls: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. @@ -120,11 +120,11 @@ Common calls: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. diff --git a/cmd/gortex/testdata/agent-render/claude-code.txt b/cmd/gortex/testdata/agent-render/claude-code.txt index 1c4c891d1..6676e56be 100644 --- a/cmd/gortex/testdata/agent-render/claude-code.txt +++ b/cmd/gortex/testdata/agent-render/claude-code.txt @@ -185,11 +185,11 @@ Return: one-line safe/risky/breaking verdict; broken callers, contracts, or guar ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/agents/gortex-search.md === --- name: gortex-search @@ -210,11 +210,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-add-test.md === # Add Tests with Gortex @@ -235,11 +235,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-architecture-review.md === # Review Architecture with Gortex @@ -259,11 +259,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-co-change.md === # Analyze Co-Change with Gortex @@ -283,11 +283,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-cross-repo-usage.md === # Find Cross-Repository Usage with Gortex @@ -308,11 +308,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-dataflow-trace.md === # Trace Data Flow with Gortex @@ -333,11 +333,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-debug.md === # Debug with Gortex @@ -358,11 +358,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-episode-replay.md === # Replay a Change Episode with Gortex @@ -383,11 +383,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-explore.md === # Explore a Codebase with Gortex @@ -407,11 +407,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-extract-function.md === # Extract a Function with Gortex @@ -432,11 +432,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-fix-all.md === # Fix Diagnostics with Gortex @@ -456,11 +456,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-guide.md === # Gortex Guide @@ -484,11 +484,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-impact.md === # Assess Change Impact with Gortex @@ -509,11 +509,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-incident-investigation.md === # Investigate an Incident with Gortex @@ -534,11 +534,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-onboarding.md === # Onboard to a Repository with Gortex @@ -558,11 +558,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-pr-review-agent.md === # Review a Change as a Sub-Agent @@ -582,11 +582,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-pr-review.md === # Review a Change with Gortex @@ -607,11 +607,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-quality-audit.md === # Audit Repository Quality with Gortex @@ -631,11 +631,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-refactor.md === # Refactor with Gortex @@ -655,11 +655,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-rename.md === # Rename a Symbol with Gortex @@ -680,11 +680,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-safe-edit.md === # Make a Safe Edit with Gortex @@ -704,11 +704,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/settings.json === { "hooks": { @@ -839,11 +839,11 @@ description: "Add tests for under-tested code and coverage gaps." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-architecture-review/SKILL.md === --- name: gortex-architecture-review @@ -867,11 +867,11 @@ description: "Graph-grounded architectural read of a repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-cli/SKILL.md === --- name: gortex-cli @@ -903,11 +903,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -931,11 +931,11 @@ description: "Find what changes together and hidden coupling." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -960,11 +960,11 @@ description: "Find who uses a symbol across all consumer repos." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -989,11 +989,11 @@ description: "Trace where a value flows through the code." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-debug/SKILL.md === --- name: gortex-debug @@ -1018,11 +1018,11 @@ description: "Debug a bug, trace an error, or find why something fails." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -1047,11 +1047,11 @@ description: "Reconstruct what changed in a window (postmortem, release, PR)." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-explore/SKILL.md === --- name: gortex-explore @@ -1075,11 +1075,11 @@ description: "Understand how code works or trace an execution flow." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -1104,11 +1104,11 @@ description: "Extract code into a function or method via LSP refactor." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -1132,11 +1132,11 @@ description: "Clear LSP diagnostics — one error, a file, or source.fixAll." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-guide/SKILL.md === --- name: gortex-guide @@ -1164,11 +1164,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-impact/SKILL.md === --- name: gortex-impact @@ -1193,11 +1193,11 @@ description: "Assess what breaks if you change X before editing it." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -1222,11 +1222,11 @@ description: "Walk a production symptom back to its root cause." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -1250,11 +1250,11 @@ description: "Structured tour of an unfamiliar repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -1278,11 +1278,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -1307,11 +1307,11 @@ description: "Graph-grounded review of a pending change or PR." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -1335,11 +1335,11 @@ description: "Repo-scale quality scan — dead code, hotspots, clones." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -1363,11 +1363,11 @@ description: "Rename, extract, split, or restructure code safely." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-rename/SKILL.md === --- name: gortex-rename @@ -1392,11 +1392,11 @@ description: "Rename a symbol and update every reference atomically." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -1420,11 +1420,11 @@ description: "Preview an edit's blast radius on the shadow graph before writing. ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.gortex/instructions/active.md === ## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob @@ -1445,11 +1445,11 @@ Use `recall` before revisiting prior work; `remember` immediately for a durable ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. ## Reference and discovery @@ -1478,11 +1478,11 @@ Use `recall` before revisiting prior work; `remember` immediately for a durable ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. ## Reference and discovery @@ -1536,11 +1536,11 @@ The graph remembers code; these tools remember **why you made a call**. They are ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. ## Reference and discovery @@ -1569,11 +1569,11 @@ Use `recall` before revisiting prior work; `remember` immediately for a durable ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. **Reference:** `gortex://guide` for deeper background. diff --git a/cmd/gortex/testdata/agent-render/codex.txt b/cmd/gortex/testdata/agent-render/codex.txt index 6b1765ee0..50eb4c865 100644 --- a/cmd/gortex/testdata/agent-render/codex.txt +++ b/cmd/gortex/testdata/agent-render/codex.txt @@ -98,11 +98,11 @@ description: "Add tests for under-tested code and coverage gaps." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-architecture-review/SKILL.md === --- name: gortex-architecture-review @@ -127,11 +127,11 @@ description: "Graph-grounded architectural read of a repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-cli/SKILL.md === --- name: gortex-cli @@ -164,11 +164,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -193,11 +193,11 @@ description: "Find what changes together and hidden coupling." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -223,11 +223,11 @@ description: "Find who uses a symbol across all consumer repos." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -253,11 +253,11 @@ description: "Trace where a value flows through the code." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-debug/SKILL.md === --- name: gortex-debug @@ -283,11 +283,11 @@ description: "Debug a bug, trace an error, or find why something fails." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -313,11 +313,11 @@ description: "Reconstruct what changed in a window (postmortem, release, PR)." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-explore/SKILL.md === --- name: gortex-explore @@ -342,11 +342,11 @@ description: "Understand how code works or trace an execution flow." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -372,11 +372,11 @@ description: "Extract code into a function or method via LSP refactor." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -401,11 +401,11 @@ description: "Clear LSP diagnostics — one error, a file, or source.fixAll." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-guide/SKILL.md === --- name: gortex-guide @@ -434,11 +434,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-impact/SKILL.md === --- name: gortex-impact @@ -464,11 +464,11 @@ description: "Assess what breaks if you change X before editing it." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -494,11 +494,11 @@ description: "Walk a production symptom back to its root cause." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -523,11 +523,11 @@ description: "Structured tour of an unfamiliar repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -552,11 +552,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -582,11 +582,11 @@ description: "Graph-grounded review of a pending change or PR." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -611,11 +611,11 @@ description: "Repo-scale quality scan — dead code, hotspots, clones." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -640,11 +640,11 @@ description: "Rename, extract, split, or restructure code safely." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-rename/SKILL.md === --- name: gortex-rename @@ -670,11 +670,11 @@ description: "Rename a symbol and update every reference atomically." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -699,11 +699,11 @@ description: "Preview an edit's blast radius on the shadow graph before writing. ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.codex/AGENTS.md === ## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob @@ -725,11 +725,11 @@ Use `recall` before revisiting prior work; `remember` immediately for a durable ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. ## Reference and discovery @@ -757,11 +757,11 @@ Return: one-line safe/risky/breaking verdict; broken callers, contracts, or guar ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. ''' === global/home/.codex/agents/gortex-search.toml === name = "gortex-search" @@ -781,11 +781,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. ''' === global/home/.codex/config.toml === [features] diff --git a/cmd/gortex/testdata/agent-render/copilot-cli.txt b/cmd/gortex/testdata/agent-render/copilot-cli.txt index 371769cbd..0b3f38e2d 100644 --- a/cmd/gortex/testdata/agent-render/copilot-cli.txt +++ b/cmd/gortex/testdata/agent-render/copilot-cli.txt @@ -51,11 +51,11 @@ Return: one-line safe/risky/breaking verdict; broken callers, contracts, or guar ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/agents/gortex-search.agent.md === --- name: gortex-search @@ -75,11 +75,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/copilot-instructions.md === ## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob @@ -101,11 +101,11 @@ Use `recall` before revisiting prior work; `remember` immediately for a durable ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. ## Reference and discovery @@ -197,11 +197,11 @@ description: "Add tests for under-tested code and coverage gaps." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-architecture-review/SKILL.md === --- name: gortex-architecture-review @@ -225,11 +225,11 @@ description: "Graph-grounded architectural read of a repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-cli/SKILL.md === --- name: gortex-cli @@ -261,11 +261,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -289,11 +289,11 @@ description: "Find what changes together and hidden coupling." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -318,11 +318,11 @@ description: "Find who uses a symbol across all consumer repos." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -347,11 +347,11 @@ description: "Trace where a value flows through the code." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-debug/SKILL.md === --- name: gortex-debug @@ -376,11 +376,11 @@ description: "Debug a bug, trace an error, or find why something fails." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -405,11 +405,11 @@ description: "Reconstruct what changed in a window (postmortem, release, PR)." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-explore/SKILL.md === --- name: gortex-explore @@ -433,11 +433,11 @@ description: "Understand how code works or trace an execution flow." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -462,11 +462,11 @@ description: "Extract code into a function or method via LSP refactor." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -490,11 +490,11 @@ description: "Clear LSP diagnostics — one error, a file, or source.fixAll." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-guide/SKILL.md === --- name: gortex-guide @@ -522,11 +522,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-impact/SKILL.md === --- name: gortex-impact @@ -551,11 +551,11 @@ description: "Assess what breaks if you change X before editing it." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -580,11 +580,11 @@ description: "Walk a production symptom back to its root cause." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -608,11 +608,11 @@ description: "Structured tour of an unfamiliar repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -636,11 +636,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -665,11 +665,11 @@ description: "Graph-grounded review of a pending change or PR." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -693,11 +693,11 @@ description: "Repo-scale quality scan — dead code, hotspots, clones." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -721,11 +721,11 @@ description: "Rename, extract, split, or restructure code safely." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-rename/SKILL.md === --- name: gortex-rename @@ -750,11 +750,11 @@ description: "Rename a symbol and update every reference atomically." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -778,8 +778,8 @@ description: "Preview an edit's blast radius on the shadow graph before writing. ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. diff --git a/cmd/gortex/testdata/agent-render/cursor.txt b/cmd/gortex/testdata/agent-render/cursor.txt index d22c19703..e441ca454 100644 --- a/cmd/gortex/testdata/agent-render/cursor.txt +++ b/cmd/gortex/testdata/agent-render/cursor.txt @@ -41,11 +41,11 @@ Use **recall** before editing known code and **remember** for durable decisions ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.cursor/mcp.json === { "mcpServers": { diff --git a/cmd/gortex/testdata/agent-render/hermes.txt b/cmd/gortex/testdata/agent-render/hermes.txt index 48f2c0585..f412de5e1 100644 --- a/cmd/gortex/testdata/agent-render/hermes.txt +++ b/cmd/gortex/testdata/agent-render/hermes.txt @@ -45,11 +45,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -80,11 +80,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -116,11 +116,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-impact/SKILL.md === --- name: gortex-impact @@ -152,11 +152,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -187,11 +187,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -223,11 +223,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -258,11 +258,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/code-intelligence/gortex-cli/SKILL.md === --- name: gortex-cli @@ -301,11 +301,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/code-intelligence/gortex/SKILL.md === --- name: gortex @@ -389,11 +389,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/debugging/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -425,11 +425,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/navigation/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -461,11 +461,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/navigation/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -497,11 +497,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/navigation/gortex-explore/SKILL.md === --- name: gortex-explore @@ -532,11 +532,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/navigation/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -567,11 +567,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/refactoring/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -603,11 +603,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/refactoring/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -638,11 +638,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/refactoring/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -673,11 +673,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/refactoring/gortex-rename/SKILL.md === --- name: gortex-rename @@ -709,11 +709,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/refactoring/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -744,11 +744,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/testing/gortex-add-test/SKILL.md === --- name: gortex-add-test @@ -780,11 +780,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/config.yaml === mcp_servers: gortex: @@ -832,11 +832,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -867,11 +867,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -903,11 +903,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-impact/SKILL.md === --- name: gortex-impact @@ -939,11 +939,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -974,11 +974,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -1010,11 +1010,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -1045,11 +1045,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/code-intelligence/gortex-cli/SKILL.md === --- name: gortex-cli @@ -1088,11 +1088,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/code-intelligence/gortex/SKILL.md === --- name: gortex @@ -1176,11 +1176,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/debugging/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -1212,11 +1212,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/navigation/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -1248,11 +1248,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/navigation/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -1284,11 +1284,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/navigation/gortex-explore/SKILL.md === --- name: gortex-explore @@ -1319,11 +1319,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/navigation/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -1354,11 +1354,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/refactoring/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -1390,11 +1390,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/refactoring/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -1425,11 +1425,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/refactoring/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -1460,11 +1460,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/refactoring/gortex-rename/SKILL.md === --- name: gortex-rename @@ -1496,11 +1496,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/refactoring/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -1531,11 +1531,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/testing/gortex-add-test/SKILL.md === --- name: gortex-add-test @@ -1567,8 +1567,8 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. diff --git a/cmd/gortex/testdata/agent-render/opencode.txt b/cmd/gortex/testdata/agent-render/opencode.txt index b9038ec84..16a68158a 100644 --- a/cmd/gortex/testdata/agent-render/opencode.txt +++ b/cmd/gortex/testdata/agent-render/opencode.txt @@ -59,11 +59,11 @@ description: "Add tests for under-tested code and coverage gaps." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-architecture-review.md === --- description: "Graph-grounded architectural read of a repo." @@ -87,11 +87,11 @@ description: "Graph-grounded architectural read of a repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-co-change.md === --- description: "Find what changes together and hidden coupling." @@ -115,11 +115,11 @@ description: "Find what changes together and hidden coupling." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-cross-repo-usage.md === --- description: "Find who uses a symbol across all consumer repos." @@ -144,11 +144,11 @@ description: "Find who uses a symbol across all consumer repos." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-dataflow-trace.md === --- description: "Trace where a value flows through the code." @@ -173,11 +173,11 @@ description: "Trace where a value flows through the code." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-debug.md === --- description: "Debug a bug, trace an error, or find why something fails." @@ -202,11 +202,11 @@ description: "Debug a bug, trace an error, or find why something fails." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-episode-replay.md === --- description: "Reconstruct what changed in a window (postmortem, release, PR)." @@ -231,11 +231,11 @@ description: "Reconstruct what changed in a window (postmortem, release, PR)." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-explore.md === --- description: "Understand how code works or trace an execution flow." @@ -259,11 +259,11 @@ description: "Understand how code works or trace an execution flow." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-extract-function.md === --- description: "Extract code into a function or method via LSP refactor." @@ -288,11 +288,11 @@ description: "Extract code into a function or method via LSP refactor." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-fix-all.md === --- description: "Clear LSP diagnostics — one error, a file, or source.fixAll." @@ -316,11 +316,11 @@ description: "Clear LSP diagnostics — one error, a file, or source.fixAll." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-guide.md === --- description: "Gortex reference: available tools, graph schema, and workflow." @@ -348,11 +348,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-impact.md === --- description: "Assess what breaks if you change X before editing it." @@ -377,11 +377,11 @@ description: "Assess what breaks if you change X before editing it." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-incident-investigation.md === --- description: "Walk a production symptom back to its root cause." @@ -406,11 +406,11 @@ description: "Walk a production symptom back to its root cause." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-onboarding.md === --- description: "Structured tour of an unfamiliar repo." @@ -434,11 +434,11 @@ description: "Structured tour of an unfamiliar repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-pr-review-agent.md === --- description: "Coding-agent review verdict via the gortex review verb." @@ -462,11 +462,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-pr-review.md === --- description: "Graph-grounded review of a pending change or PR." @@ -491,11 +491,11 @@ description: "Graph-grounded review of a pending change or PR." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-quality-audit.md === --- description: "Repo-scale quality scan — dead code, hotspots, clones." @@ -519,11 +519,11 @@ description: "Repo-scale quality scan — dead code, hotspots, clones." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-refactor.md === --- description: "Rename, extract, split, or restructure code safely." @@ -547,11 +547,11 @@ description: "Rename, extract, split, or restructure code safely." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-rename.md === --- description: "Rename a symbol and update every reference atomically." @@ -576,11 +576,11 @@ description: "Rename a symbol and update every reference atomically." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-safe-edit.md === --- description: "Preview an edit's blast radius on the shadow graph before writing." @@ -604,11 +604,11 @@ description: "Preview an edit's blast radius on the shadow graph before writing. ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/opencode.json === { "$schema": "https://opencode.ai/config.json", @@ -1128,11 +1128,11 @@ description: "Add tests for under-tested code and coverage gaps." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-architecture-review/SKILL.md === --- name: gortex-architecture-review @@ -1157,11 +1157,11 @@ description: "Graph-grounded architectural read of a repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-cli/SKILL.md === --- name: gortex-cli @@ -1194,11 +1194,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -1223,11 +1223,11 @@ description: "Find what changes together and hidden coupling." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -1253,11 +1253,11 @@ description: "Find who uses a symbol across all consumer repos." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -1283,11 +1283,11 @@ description: "Trace where a value flows through the code." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-debug/SKILL.md === --- name: gortex-debug @@ -1313,11 +1313,11 @@ description: "Debug a bug, trace an error, or find why something fails." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -1343,11 +1343,11 @@ description: "Reconstruct what changed in a window (postmortem, release, PR)." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-explore/SKILL.md === --- name: gortex-explore @@ -1372,11 +1372,11 @@ description: "Understand how code works or trace an execution flow." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -1402,11 +1402,11 @@ description: "Extract code into a function or method via LSP refactor." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -1431,11 +1431,11 @@ description: "Clear LSP diagnostics — one error, a file, or source.fixAll." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-guide/SKILL.md === --- name: gortex-guide @@ -1464,11 +1464,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-impact/SKILL.md === --- name: gortex-impact @@ -1494,11 +1494,11 @@ description: "Assess what breaks if you change X before editing it." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -1524,11 +1524,11 @@ description: "Walk a production symptom back to its root cause." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -1553,11 +1553,11 @@ description: "Structured tour of an unfamiliar repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -1582,11 +1582,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -1612,11 +1612,11 @@ description: "Graph-grounded review of a pending change or PR." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -1641,11 +1641,11 @@ description: "Repo-scale quality scan — dead code, hotspots, clones." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -1670,11 +1670,11 @@ description: "Rename, extract, split, or restructure code safely." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-rename/SKILL.md === --- name: gortex-rename @@ -1700,11 +1700,11 @@ description: "Rename a symbol and update every reference atomically." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -1729,8 +1729,8 @@ description: "Preview an edit's blast radius on the shadow graph before writing. ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. diff --git a/cmd/gortex/upgrade_kill_scenario_test.go b/cmd/gortex/upgrade_kill_scenario_test.go new file mode 100644 index 000000000..a583d3b88 --- /dev/null +++ b/cmd/gortex/upgrade_kill_scenario_test.go @@ -0,0 +1,480 @@ +//go:build acceptance && (linux || darwin) + +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph/store_sqlite" + gortexmcp "github.com/zzet/gortex/internal/mcp" +) + +// The upgrade-with-kill scenario runs a real gortex binary over a copy of an +// old store: it SIGKILLs the daemon as soon as it reports a schema migration, +// restarts it, and records the whole upgrade window as a JSON receipt. +// +// GORTEX_UPGRADE_SCENARIO_BINARY= +// GORTEX_UPGRADE_SCENARIO_STORE= +// GORTEX_UPGRADE_SCENARIO_REPO= +// GORTEX_UPGRADE_SCENARIO_OUT= (optional) +// go test -tags acceptance -run '^TestUpgradeKillScenario$' ./cmd/gortex/ +// +// The repository is tracked in the scenario's private home, and every sample +// opens a fresh MCP session from it: it times tools/list and makes one graph +// call. During a migration the same listener must answer tools/list within +// one second and graph calls must report retryable repo_loading:migrating. +// A step transition is retained separately from a stable migration sample. + +const ( + upgradeScenarioBinaryEnv = "GORTEX_UPGRADE_SCENARIO_BINARY" + upgradeScenarioStoreEnv = "GORTEX_UPGRADE_SCENARIO_STORE" + upgradeScenarioRepoEnv = "GORTEX_UPGRADE_SCENARIO_REPO" + upgradeScenarioOutEnv = "GORTEX_UPGRADE_SCENARIO_OUT" + + upgradeScenarioNoSocket = "no_socket" + // upgradeScenarioReadyWait bounds how long the restarted daemon may serve + // before the tracked repository answers a graph call. + upgradeScenarioReadyWait = 20 * time.Minute +) + +type upgradeScenarioSample struct { + AtMillis int64 `json:"at_ms"` + Phase string `json:"phase"` + Socket string `json:"socket"` // "none" or "accepting" + WALBytes int64 `json:"wal_bytes"` + // ToolsListMillis covers what a fresh editor session waits for: dial, + // initialize and tools/list. + ToolsListMillis int64 `json:"tools_list_ms,omitempty"` + ToolsList string `json:"tools_list"` + // GraphCall is the answer to a symbol search from the tracked + // repository: "ok", the refusal's error_code (with its phase, if any), + // or no_socket. + GraphCall string `json:"graph_call"` + GraphRetryable *bool `json:"graph_retryable,omitempty"` + StableMigration bool `json:"stable_migration"` +} + +type upgradeScenarioRun struct { + PID int `json:"pid"` + Killed bool `json:"killed"` + KilledAtMillis int64 `json:"killed_at_ms,omitempty"` + KilledInVersion int `json:"killed_in_migration_version,omitempty"` + ServingAtMillis int64 `json:"serving_at_ms,omitempty"` + // ReadyAtMillis is the first sample whose graph call answered. + ReadyAtMillis int64 `json:"ready_at_ms,omitempty"` + BytesWritten *int64 `json:"bytes_written,omitempty"` // Linux /proc//io only + Samples []upgradeScenarioSample `json:"samples"` +} + +type upgradeScenarioReceipt struct { + FromVersion int `json:"from_version"` + Repo string `json:"repo"` + StoreBytesBefore int64 `json:"store_bytes_before"` + StoreBytesAfter int64 `json:"store_bytes_after"` + // WALPeakBytes covers the upgrade, until the restarted daemon serves; + // WarmupWALPeakBytes covers the load of the tracked repository after it. + WALPeakBytes int64 `json:"wal_peak_bytes"` + WarmupWALPeakBytes int64 `json:"warmup_wal_peak_bytes"` + BackupBytes int64 `json:"backup_bytes"` + // WallMillis runs from the first start until the restarted daemon serves. + WallMillis int64 `json:"wall_ms"` + Interrupted upgradeScenarioRun `json:"interrupted"` + Restarted upgradeScenarioRun `json:"restarted"` + StableMigratingSamples int `json:"stable_migrating_samples"` + MigratingSamples int `json:"migrating_samples"` + MigratingSamplesWithSocket int `json:"migrating_samples_with_socket"` + ServingDuringMigration bool `json:"serving_during_migration"` + // ToolsListMaxMillis is the slowest tools/list among the samples that + // got one. + ToolsListMaxMillis int64 `json:"tools_list_max_ms"` + // GraphCalls counts every sample's graph-call answer. + GraphCalls map[string]int `json:"graph_calls"` + Note string `json:"note"` +} + +func TestUpgradeKillScenario(t *testing.T) { + binary, source, repo := os.Getenv(upgradeScenarioBinaryEnv), os.Getenv(upgradeScenarioStoreEnv), os.Getenv(upgradeScenarioRepoEnv) + if binary == "" || source == "" || repo == "" { + t.Skipf("set %s, %s and %s to run the upgrade-with-kill scenario", upgradeScenarioBinaryEnv, upgradeScenarioStoreEnv, upgradeScenarioRepoEnv) + } + // A short root keeps the socket path under the platform's sun_path limit. + root, err := os.MkdirTemp("", "gx-upgrade") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + store := filepath.Join(root, "store", "store.sqlite") + if err := os.MkdirAll(filepath.Dir(store), 0o755); err != nil { + t.Fatal(err) + } + for _, suffix := range []string{"", "-wal"} { + // The store must exist; its WAL is optional. + if err := copyScenarioFile(source+suffix, store+suffix); err != nil && (suffix == "" || !os.IsNotExist(err)) { + t.Fatal(err) + } + } + from := scenarioUserVersion(t, store) + receipt := upgradeScenarioReceipt{FromVersion: from, Repo: repo, StoreBytesBefore: fileSize(store), GraphCalls: map[string]int{}} + + home := filepath.Join(root, "home") + env := append(os.Environ(), + "HOME="+home, + "XDG_CONFIG_HOME="+filepath.Join(home, "config"), + "XDG_DATA_HOME="+filepath.Join(home, "data"), + "XDG_CACHE_HOME="+filepath.Join(home, "cache"), + "XDG_STATE_HOME="+filepath.Join(home, "state"), + "GORTEX_DAEMON_SOCKET="+filepath.Join(root, "d.sock"), + "GORTEX_DAEMON_STATEFILE="+filepath.Join(root, "state.json"), + ) + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(root, "state.json")) + socket := filepath.Join(root, "d.sock") + + // Track the repository the way its user did, so the restarted daemon + // loads it from the upgraded store. With no daemon running, track only + // writes the config. + track := exec.Command(binary, "track", repo, "--no-progress") + track.Env = append(env, "GORTEX_AUTOSTART=off") + if out, err := track.CombinedOutput(); err != nil { + t.Fatalf("track %s: %v\n%s", repo, err, out) + } + + start := time.Now() + receipt.Interrupted = runScenarioDaemon(t, binary, env, store, socket, repo, start, true) + receipt.Restarted = runScenarioDaemon(t, binary, env, store, socket, repo, start, false) + receipt.WallMillis = receipt.Restarted.ServingAtMillis + receipt.StoreBytesAfter = fileSize(store) + receipt.BackupBytes = fileSize(store_sqlite.UpgradeBackupPath(store, from)) + for _, run := range []upgradeScenarioRun{receipt.Interrupted, receipt.Restarted} { + for _, s := range run.Samples { + if run.ServingAtMillis > 0 && s.AtMillis >= run.ServingAtMillis { + receipt.WarmupWALPeakBytes = max(receipt.WarmupWALPeakBytes, s.WALBytes) + } else { + receipt.WALPeakBytes = max(receipt.WALPeakBytes, s.WALBytes) + } + if s.ToolsList == "ok" { + receipt.ToolsListMaxMillis = max(receipt.ToolsListMaxMillis, s.ToolsListMillis) + } + receipt.GraphCalls[s.GraphCall]++ + if s.Phase == string(daemon.StartupMigrating) { + receipt.MigratingSamples++ + if s.StableMigration { + receipt.StableMigratingSamples++ + } + if err := validateMigrationSocketSample(s); err != nil { + t.Errorf("migration sample %dms: %v", s.AtMillis, err) + } + if s.Socket == "accepting" { + receipt.MigratingSamplesWithSocket++ + } + } + } + } + receipt.ServingDuringMigration = receipt.MigratingSamplesWithSocket > 0 + receipt.Note = "Migration-window metadata is gated at <1s with an accepting socket; stable migration steps require retryable repo_loading{phase: migrating}. Step-transition samples remain explicit and do not invent graph-phase evidence." + if receipt.StableMigratingSamples == 0 { + t.Error("AC6 has no stable migrating sample; graph-phase evidence is unavailable") + } + + if out := os.Getenv(upgradeScenarioOutEnv); out != "" { + blob, err := json.MarshalIndent(receipt, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(out, append(blob, '\n'), 0o644); err != nil { + t.Fatal(err) + } + } + t.Logf("upgrade v%d: wall %dms, ready %dms, WAL peak %d (warmup %d), store %d -> %d, backup %d, migrating samples %d (with socket %d), tools/list max %dms, graph calls %v", + from, receipt.WallMillis, receipt.Restarted.ReadyAtMillis, receipt.WALPeakBytes, receipt.WarmupWALPeakBytes, receipt.StoreBytesBefore, receipt.StoreBytesAfter, + receipt.BackupBytes, receipt.MigratingSamples, receipt.MigratingSamplesWithSocket, receipt.ToolsListMaxMillis, receipt.GraphCalls) + if got := scenarioUserVersion(t, store); got <= from { + t.Fatalf("the restarted daemon left the store at v%d", got) + } + if !receipt.Interrupted.Killed { + t.Fatalf("the first daemon finished before reporting a migration; nothing was interrupted") + } + if n := receipt.GraphCalls[string(gortexmcp.ErrCodeRepoNotTracked)]; n > 0 { + t.Errorf("%d graph calls from the tracked repository %s answered repo_not_tracked", n, repo) + } +} + +// runScenarioDaemon starts the daemon in the foreground and samples its +// startup until it serves and the tracked repository answers a graph call; +// with kill set it SIGKILLs the daemon at the first migrating sample instead. +func runScenarioDaemon(t *testing.T, binary string, env []string, store, socket, repo string, origin time.Time, kill bool) upgradeScenarioRun { + t.Helper() + cmd := exec.Command(binary, "daemon", "start", "--no-progress", "--backend-path", store) + cmd.Env = env + cmd.Stdout, cmd.Stderr = io.Discard, io.Discard + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + run := upgradeScenarioRun{PID: cmd.Process.Pid} + exited := make(chan error, 1) + go func() { exited <- cmd.Wait() }() + var servingAt time.Time + deadline := time.Now().Add(2 * time.Hour) + for time.Now().Before(deadline) { + sample := upgradeScenarioSample{ + AtMillis: time.Since(origin).Milliseconds(), + Socket: "none", + WALBytes: fileSize(store + "-wal"), + ToolsList: upgradeScenarioNoSocket, + GraphCall: upgradeScenarioNoSocket, + } + st, ok := daemon.ReadRuntimeStateOf(cmd.Process.Pid) + if ok { + sample.Phase = string(st.StartupPhase) + } + if conn, err := net.DialTimeout("unix", socket, 200*time.Millisecond); err == nil { + sample.Socket = "accepting" + _ = conn.Close() + sample.ToolsListMillis, sample.ToolsList, sample.GraphCall, sample.GraphRetryable = scenarioProbe(socket, repo) + } + after, afterOK := daemon.ReadRuntimeStateOf(cmd.Process.Pid) + sample.StableMigration = ok && afterOK && st.StartupPhase == daemon.StartupMigrating && after.StartupPhase == daemon.StartupMigrating && st.MigrationVersion == after.MigrationVersion + run.Samples = append(run.Samples, sample) + if kill && sample.Phase == string(daemon.StartupMigrating) { + run.BytesWritten = procBytesWritten(cmd.Process.Pid) + _ = cmd.Process.Signal(syscall.SIGKILL) + <-exited + run.Killed, run.KilledAtMillis, run.KilledInVersion = true, sample.AtMillis, st.MigrationVersion + return run + } + if servingAt.IsZero() && sample.Socket == "accepting" && sample.Phase == string(daemon.StartupServing) { + servingAt = time.Now() + run.ServingAtMillis = sample.AtMillis + run.BytesWritten = procBytesWritten(cmd.Process.Pid) + } + // A first daemon that serves without migrating is stopped at once + // (the scenario then fails); the restarted one is followed through + // the warmup a session meets after the migration. + if !servingAt.IsZero() && (kill || sample.GraphCall == "ok") { + if sample.GraphCall == "ok" { + run.ReadyAtMillis = sample.AtMillis + } + stop := exec.Command(binary, "daemon", "stop", "--no-progress") + stop.Env = env + _ = stop.Run() + select { + case <-exited: + case <-time.After(2 * time.Minute): + _ = cmd.Process.Kill() + <-exited + } + return run + } + if !servingAt.IsZero() && time.Since(servingAt) > upgradeScenarioReadyWait { + _ = cmd.Process.Kill() + <-exited + t.Fatalf("the tracked repository answered no graph call within %s of serving; last answer: %s", upgradeScenarioReadyWait, sample.GraphCall) + } + select { + case err := <-exited: + t.Fatalf("daemon exited during startup: %v", err) + case <-time.After(100 * time.Millisecond): + } + } + _ = cmd.Process.Kill() + t.Fatal("the daemon did not answer a graph call within the scenario deadline") + return run +} + +// scenarioProbe opens a fresh MCP session from repo, the way an editor's +// proxy does: it times dial, initialize and tools/list, then makes one graph +// call. It returns the tools/list time and outcome and the graph answer. +func scenarioProbe(socket, repo string) (int64, string, string, *bool) { + began := time.Now() + elapsed := func() int64 { return time.Since(began).Milliseconds() } + c, err := daemon.DialToWithDeadline(socket, daemon.Handshake{Mode: daemon.ModeMCP, CWD: repo, ClientName: "upgrade-scenario"}, began.Add(time.Second)) + if err != nil { + return elapsed(), "dial: " + err.Error(), "dial: " + err.Error(), nil + } + defer c.Close() + initialized, err := scenarioRequest(c, 1, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"upgrade-scenario","version":"1"}}}`) + if outcome := scenarioOutcome(initialized, err); outcome != "ok" { + return elapsed(), "initialize: " + outcome, "initialize: " + outcome, nil + } + if err := c.WriteMCPFrame([]byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)); err != nil { + return elapsed(), "initialized: " + err.Error(), "initialized: " + err.Error(), nil + } + listed, err := scenarioRequest(c, 2, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) + toolsListMillis, toolsList := elapsed(), scenarioOutcome(listed, err) + if err := c.Conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { + return toolsListMillis, toolsList, "error: " + err.Error(), nil + } + called, err := scenarioRequest(c, 3, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search","arguments":{"operation":"symbols","query":"Open"}}}`) + var retryable *bool + if remote, ok := called["error"].(map[string]any); ok { + if data, ok := remote["data"].(map[string]any); ok { + if value, ok := data["retryable"].(bool); ok { + retryable = &value + } + } + } + return toolsListMillis, toolsList, scenarioOutcome(called, err), retryable +} + +// scenarioRequest sends one request and returns the reply paired to its id, +// skipping whatever the server sends in between. +func scenarioRequest(c *daemon.Client, id int, frame string) (map[string]any, error) { + if err := c.WriteMCPFrame([]byte(frame)); err != nil { + return nil, err + } + for { + raw, err := c.ReadMCPFrame() + if err != nil { + return nil, err + } + var reply map[string]any + if err := json.Unmarshal(raw, &reply); err != nil { + return nil, err + } + if got, ok := reply["id"].(float64); ok && int(got) == id && reply["method"] == nil { + return reply, nil + } + } +} + +// scenarioOutcome names a reply: "ok", the refusal's error_code with its +// phase when it carries one (repo_loading{phase: migrating}), or the failure. +func scenarioOutcome(reply map[string]any, err error) string { + if err != nil { + return "error: " + err.Error() + } + if rpcErr, ok := reply["error"].(map[string]any); ok { + data, _ := rpcErr["data"].(map[string]any) + code, _ := data["error_code"].(string) + if code == "" { + return fmt.Sprintf("error: %v", rpcErr["message"]) + } + if phase, _ := data["phase"].(string); phase != "" { + return fmt.Sprintf("%s{phase: %s}", code, phase) + } + return code + } + if result, _ := reply["result"].(map[string]any); result["isError"] == true { + // A tool error carries its structured error as the text block. + content, _ := result["content"].([]any) + if len(content) > 0 { + block, _ := content[0].(map[string]any) + text, _ := block["text"].(string) + var structured gortexmcp.StructuredError + if json.Unmarshal([]byte(text), &structured) == nil && structured.ErrorCode != "" { + return "tool_error: " + string(structured.ErrorCode) + } + } + return "tool_error" + } + return "ok" +} + +// procBytesWritten reads the bytes a process caused to be written to storage, +// where the OS reports it. +func procBytesWritten(pid int) *int64 { + blob, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "io")) + if err != nil { + return nil + } + for _, line := range strings.Split(string(blob), "\n") { + if value, ok := strings.CutPrefix(line, "write_bytes: "); ok { + if n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64); err == nil { + return &n + } + } + } + return nil +} + +func scenarioUserVersion(t *testing.T, store string) int { + t.Helper() + db, err := sql.Open("sqlite", "file:"+store+"?mode=ro") + if err != nil { + t.Fatal(err) + } + defer db.Close() + var version int + if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { + t.Fatal(err) + } + return version +} + +func copyScenarioFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + return out.Close() +} + +func fileSize(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.Size() +} + +// No fixture is opened by this predicate or its negative controls. +func validateMigrationSocketSample(s upgradeScenarioSample) error { + if s.Socket != "accepting" || s.ToolsList != "ok" { + return fmt.Errorf("migration metadata unavailable: socket=%s tools/list=%s", s.Socket, s.ToolsList) + } + if s.ToolsListMillis >= 1000 { + return fmt.Errorf("tools/list took %dms; millisecond evidence must be below 1000ms", s.ToolsListMillis) + } + if s.StableMigration && (s.GraphCall != "repo_loading{phase: migrating}" || s.GraphRetryable == nil || !*s.GraphRetryable) { + return fmt.Errorf("stable migration graph reply is not retryable migrating: %s", s.GraphCall) + } + return nil +} + +func TestUpgradeMigrationSocketSampleGate(t *testing.T) { + retryable := true + valid := upgradeScenarioSample{Socket: "accepting", ToolsList: "ok", ToolsListMillis: 2, GraphCall: "repo_loading{phase: migrating}", GraphRetryable: &retryable, StableMigration: true} + if err := validateMigrationSocketSample(valid); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*upgradeScenarioSample){ + func(s *upgradeScenarioSample) { s.Socket = "none" }, + func(s *upgradeScenarioSample) { s.ToolsList = upgradeScenarioNoSocket }, + func(s *upgradeScenarioSample) { s.ToolsListMillis = 1000 }, + func(s *upgradeScenarioSample) { s.GraphCall = "repo_not_tracked" }, + func(s *upgradeScenarioSample) { s.GraphRetryable = nil }, + } { + broken := valid + mutate(&broken) + if err := validateMigrationSocketSample(broken); err == nil { + t.Fatalf("invalid migration evidence passed: %+v", broken) + } + } + transition := valid + transition.StableMigration = false + transition.GraphCall = "repo_loading{phase: snapshot_loaded}" + if err := validateMigrationSocketSample(transition); err != nil { + t.Fatal("a real transition must not claim a stable migration phase", err) + } +} diff --git a/docs/cli.md b/docs/cli.md index c0620031f..4b5951b74 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -535,6 +535,7 @@ gortex savings [--verbose] [--json] # Today / Last 7 days / All time bar-ch gortex bench # user-facing benchmark suite (recall / tokens / tokens-efficiency / perf / daemon-latency / embedders / swebench / all) gortex audit [--badge|--format svg|json|text] # A-F repo health grade + README-ready SVG shield gortex gain [--since 7d] # forward-looking per-call USD savings + optional history slice +gortex store cleanup-backup [--backend-path P] # remove the store backups taken before schema upgrades (the daemon also prunes them after 14 days) gortex version ``` diff --git a/docs/mcp.md b/docs/mcp.md index b3f836714..9e8eb3923 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -472,6 +472,70 @@ the first attests *what bytes* are on disk, the second whether the write happened at all. A call abandoned before its response returns no evidence block — only the receipt — which is exactly when `disk_status` is the field you need. +### Source mutation acknowledgement policy + +Set the daemon's startup configuration to choose when source edits acknowledge: + +```yaml +edit: + ack: full +``` + +`full` is the default. It waits for the complete admitted patch, for up to the +existing three-second publication budget. `local` returns after the disk commit, +exact file projection when available, and watcher admission. Both policies +release source path locks before waiting; a graph failure never reverses a disk +commit. Invalid modes are rejected. This is daemon configuration, not a tool +argument, and the response's `ack_mode` records the policy used for the operation. +Existing `require_fresh`, `freshness_scope`, and `wait_deadline` metadata can +request a specific tier under either policy. + +Each committed source-file response adds `commit_seq`, the actual raw-byte +`content_sha256` when known, `readback_exact`, and `graph_status_hint`. +`file_view` is an object: `state`, `delta_state`, `counts`, `symbols`, +`truncated`, and `syntax_health`. Available counts include `before`, `after`, +`added`, `removed`, `changed`, and `total_changes`; they remain complete when +`symbols` displays only the first 20 deterministic changes. Each displayed row +has `change`, `id`, `kind`, `start_line`, `end_line`, and `signature`. +An unauthenticated prior symbol set yields an unavailable delta and null counts. +Syntax health is checked only when the acknowledgement's parse supplies an exact +verdict; unknown health has `checked:false` and null `errors`. + +`readback_exact:true` requires that this write's projection is still live. +`tier_state.file` describes that physical file view independently of the +watcher-owned `file_graph`, `relations`, and `derived` stages. A newer write or +invalidation can make a replay's file view unavailable or historical. Replays +and `mutation_status` refresh live receipt evidence without parsing or writing +again. Transactional batches retain journal authority and report per-file +`file_views`; restarting does not reconstruct an original process-local +projection sequence from the journal. Report full/default and explicit-local +measurements separately; changing the mode does not establish a latency target. + +### Publication tiers and freshness + +`readback_exact:true` means the written bytes can be used immediately; continue +without polling. Graph relations and derived analyses can publish later. Check +`graph_freshness` before acting on callers, usages, impact, or rename plans. +Before a rename, deletion, or signature change, send `require_fresh:true`, +`freshness_scope:"relations"`, and an absolute RFC3339 `wait_deadline`. +`freshness_scope` accepts `file`, `relations`, and `derived` as request metadata; +it is intentionally absent from tool schemas. Text and declaration capability +scopes do not select a publication tier. + +Pending or failed answers carry a visible tier line in text/TOON/compact output; +GCX preserves its `GCX1` header and places the line immediately after it. JSON +carries the same fields in its freshness block. An idle fresh answer gains no +fields. Never classify a symbol as dead while its file is pending. + +`mutation_status` reads live `ack_at`, `file_graph_at`, `relations_at`, and +`derived_at` stamps when a watcher ticket observed them. Missing stamps mean +unobserved work. Unknown receipt or mutation IDs return `state:"lost"` immediately; +a lost receipt cannot certify freshness. After restart, relation queries remain +pending until the shared startup resolver has published. File reads still check +current disk state. CLI verbs print the freshness line on stderr; +`--require-fresh` exits with code 4 when the requested tier remains pending or +failed. + ## Agent-optimized (token efficiency) | Tool | Description | diff --git a/docs/upgrade-v0.64.12.md b/docs/upgrade-v0.64.12.md index 4488dbc70..3e73ce736 100644 --- a/docs/upgrade-v0.64.12.md +++ b/docs/upgrade-v0.64.12.md @@ -19,3 +19,85 @@ Check `gortex daemon status`: the removed repository should be absent and the remaining repositories should become ready. See the [onboarding troubleshooting guide](onboarding.md#recovering-from-a-runaway-warmup). + +## The store schema upgrade + +The first daemon start after upgrading from v0.64.10 (store schema v21) or +v0.64.11 (v40) upgrades the graph store in place before the daemon opens its +socket. While it runs, `gortex daemon start` shows `migrating schema vN +(name)` and the daemon answers nothing — `tools/list` included — until the +upgrade has finished. Nothing is reindexed. + +How long it takes grows with the store. Measured locally on the 1.5 GB store +v0.64.10 writes for this repository (190k nodes, 1.16M edges), the upgrade +from v21 took about 13 seconds, grew a 0.6 GB write-ahead log and added 66 MB +to the store; from v0.64.11's v40 it took under 3 seconds. Taking the backup +(below) adds the time to copy the store once. Numbers for large +multi-repository stores have not been measured yet. + +### Free disk space + +Before the first step, the daemon estimates how much disk the upgrade needs +(the write-ahead log it grows plus the main file's growth, plus 64 MiB) and +compares it with the free space on the store's volume. When there is not +enough, it refuses before touching the store and exits, and +`gortex daemon start` prints: + +```text +daemon exited during startup: not enough free disk space to upgrade the +sqlite store schema: upgrading from v21 to v needs bytes +(… MiB) free, bytes (… MiB) available; the store was not modified — +free disk space on that volume or move the store to a larger one, then start +again (--backend-path starts the daemon on another volume) (…) +``` + +For the 1.5 GB store above the check asks for about 1.6 GB of free space. + +Free the space and start again, or move the stopped store (`store.sqlite` +plus any `store.sqlite-wal`) to a larger volume and start the daemon with +`--backend-path `. A service manager that restarts the daemon in a +loop keeps hitting the same refusal until space is freed. + +### The pre-upgrade backup + +When the volume has room for the upgrade and a copy of the store, the daemon +first copies the store to `store.sqlite.v.backup` next to it +(`~/.gortex/store/store.sqlite.v21.backup` for a default v0.64.10 install). +The backup is one self-contained SQLite file at the old schema version. When +there is room for the upgrade but not for the copy, the upgrade runs without +a backup and the daemon log says why (`upgrading the store schema without a +backup`). A backup that already exists for that version is kept, so a restart +after an interrupted upgrade never replaces it. + +The daemon deletes backups older than 14 days when it starts. To remove them +sooner: + +```sh +gortex store cleanup-backup # or: --backend-path +``` + +### Rolling back to v0.64.10 + +v0.64.10 cannot read an upgraded store: started on one, it deletes the store +and reindexes every tracked repository from source. It leaves the backup file +alone. (v0.64.11 refuses an upgraded store instead, with `stored v, +supported v40`.) To go back to the pre-upgrade store without that reindex, +restore the backup before starting the old release: + +```sh +gortex daemon stop +cd ~/.gortex/store # or the directory of your --backend-path +rm -f store.sqlite store.sqlite-wal store.sqlite-shm +mv store.sqlite.v21.backup store.sqlite +# install v0.64.10 again, then: +gortex daemon start +``` + +Without a backup, the way back is that full reindex. + +### An interrupted upgrade + +A daemon killed or crashing during the upgrade leaves the store at its old +version; the next start runs the upgrade again from the beginning and +completes it. The schema version is written only after every step and index +rebuild has finished. diff --git a/internal/analysis/scaffold.go b/internal/analysis/scaffold.go index f10572fbb..f5a22020b 100644 --- a/internal/analysis/scaffold.go +++ b/internal/analysis/scaffold.go @@ -21,7 +21,7 @@ import ( // // This interface avoids a circular dependency with the indexer package. type SourceReader interface { - Graph() graph.Store + Graph() graph.Reader ResolveFilePath(graphPath string) string } @@ -62,7 +62,7 @@ func GenerateScaffold(engine *query.Engine, reader SourceReader, exampleID, newN if absPath == "" { return nil, fmt.Errorf("could not resolve abs path for %s", node.FilePath) } - source, err := readSourceLines(absPath, node.StartLine, node.EndLine) + source, _, endLine, err := scaffoldSource(reader, node, absPath) if err != nil { return nil, fmt.Errorf("failed to read source for %s: %w", exampleID, err) } @@ -75,7 +75,7 @@ func GenerateScaffold(engine *query.Engine, reader SourceReader, exampleID, newN // Add the new symbol edit — insert after the example symbol result.Edits = append(result.Edits, ScaffoldEdit{ FilePath: node.FilePath, - InsertionLine: node.EndLine + 1, + InsertionLine: endLine + 1, Code: substituted, Reason: "new_symbol", }) @@ -85,7 +85,7 @@ func GenerateScaffold(engine *query.Engine, reader SourceReader, exampleID, newN callerNodes := filterCallerNodes(callerSG, exampleID) if len(callerNodes) > 0 { - regEdit := generateRegistrationCode(g, callerNodes, node, newName) + regEdit := generateRegistrationCode(g, reader, callerNodes, node, newName) if regEdit != nil { result.Edits = append(result.Edits, *regEdit) } @@ -104,6 +104,18 @@ func GenerateScaffold(engine *query.Engine, reader SourceReader, exampleID, newN return result, nil } +// A live caller can resolve declarations against the exact current bytes. +// Static analysis fixtures retain the ordinary source-reader contract. +func scaffoldSource(reader SourceReader, node *graph.Node, path string) (string, int, int, error) { + if live, ok := reader.(interface { + ScaffoldNodeSource(*graph.Node) (string, int, int, error) + }); ok { + return live.ScaffoldNodeSource(node) + } + text, err := readSourceLines(path, node.StartLine, node.EndLine) + return text, node.StartLine, node.EndLine, err +} + // readSourceLines reads lines [startLine, endLine] (1-indexed) from an // absolute file path. func readSourceLines(absPath string, startLine, endLine int) (string, error) { @@ -153,7 +165,7 @@ func filterCallerNodes(sg *query.SubGraph, exampleID string) []*graph.Node { // generateRegistrationCode creates a registration/wiring edit by analyzing how // the example symbol is called by its depth-1 callers. -func generateRegistrationCode(g graph.Store, callers []*graph.Node, example *graph.Node, newName string) *ScaffoldEdit { +func generateRegistrationCode(g graph.Reader, reader SourceReader, callers []*graph.Node, example *graph.Node, newName string) *ScaffoldEdit { if len(callers) == 0 { return nil } @@ -176,10 +188,13 @@ func generateRegistrationCode(g graph.Store, callers []*graph.Node, example *gra regCode := fmt.Sprintf("// TODO: Register %s following the pattern of %s\n// See %s at line %d for the registration pattern", newName, example.Name, caller.FilePath, callLine) - insertionLine := caller.EndLine - if callLine > 0 { - insertionLine = callLine + 1 + // The generated wiring is a comment, so insert outside the current + // declaration rather than applying an old edge line inside changed text. + _, _, end, err := scaffoldSource(reader, caller, reader.ResolveFilePath(caller.FilePath)) + if err != nil { + return nil } + insertionLine := end + 1 return &ScaffoldEdit{ FilePath: caller.FilePath, @@ -191,7 +206,7 @@ func generateRegistrationCode(g graph.Store, callers []*graph.Node, example *gra // generateTestStub creates a test stub edit by finding the test file and test // functions associated with the example symbol. -func generateTestStub(g graph.Store, reader SourceReader, example *graph.Node, newName string) *ScaffoldEdit { +func generateTestStub(g graph.Reader, reader SourceReader, example *graph.Node, newName string) *ScaffoldEdit { testFilePath := deriveTestFilePath(example.FilePath) // Check if the test file exists on disk. Resolve abs path through @@ -223,7 +238,7 @@ func generateTestStub(g graph.Store, reader SourceReader, example *graph.Node, n } // Read the test function source and substitute the name - testSource, err := readSourceLines(absTestPath, testExample.StartLine, testExample.EndLine) + testSource, _, end, err := scaffoldSource(reader, testExample, absTestPath) if err != nil { return nil } @@ -232,7 +247,7 @@ func generateTestStub(g graph.Store, reader SourceReader, example *graph.Node, n return &ScaffoldEdit{ FilePath: testFilePath, - InsertionLine: testExample.EndLine + 1, + InsertionLine: end + 1, Code: substituted, Reason: "test_stub", } diff --git a/internal/analysis/scaffold_test.go b/internal/analysis/scaffold_test.go index 1ddd0d1b0..ff0e9a30f 100644 --- a/internal/analysis/scaffold_test.go +++ b/internal/analysis/scaffold_test.go @@ -22,7 +22,7 @@ type mockSourceReader struct { rootPath string } -func (m *mockSourceReader) Graph() graph.Store { return m.g } +func (m *mockSourceReader) Graph() graph.Reader { return m.g } func (m *mockSourceReader) ResolveFilePath(relPath string) string { if filepath.IsAbs(relPath) { return relPath diff --git a/internal/config/config.go b/internal/config/config.go index ce8a9b866..f868d3e9d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -482,6 +482,7 @@ type Config struct { // workspaces. Only `mode: read-only` is accepted in iteration 1. CrossWorkspaceDeps []CrossWorkspaceDep `mapstructure:"cross_workspace_deps" yaml:"cross_workspace_deps,omitempty"` + Edit EditConfig `mapstructure:"edit" yaml:"edit,omitempty"` Index IndexConfig `mapstructure:"index" yaml:"index,omitempty"` Watch WatchConfig `mapstructure:"watch" yaml:"watch,omitempty"` Query QueryConfig `mapstructure:"query" yaml:"query,omitempty"` @@ -1965,6 +1966,9 @@ func Load(configPath string) (*Config, error) { // in one pass rather than one round-trip per fix. func (c *Config) validateWorkspaceSchema() error { var errs []string + if _, err := c.Edit.AckMode(); err != nil { + errs = append(errs, err.Error()) + } if c.Project != "" && len(c.Projects) > 0 { errs = append(errs, "config: 'project' and 'projects' are mutually exclusive — pick one") } diff --git a/internal/config/edit.go b/internal/config/edit.go new file mode 100644 index 000000000..c66c93662 --- /dev/null +++ b/internal/config/edit.go @@ -0,0 +1,27 @@ +package config + +import "fmt" + +type EditAckMode string + +const ( + EditAckFull EditAckMode = "full" + EditAckLocal EditAckMode = "local" +) + +// EditConfig controls the source mutation reply policy. Empty retains the +// shipped full mode; local is always an explicit operator choice. +type EditConfig struct { + Ack EditAckMode `mapstructure:"ack" yaml:"ack,omitempty"` +} + +func (e EditConfig) AckMode() (EditAckMode, error) { + switch e.Ack { + case "", EditAckFull: + return EditAckFull, nil + case EditAckLocal: + return EditAckLocal, nil + default: + return "", fmt.Errorf("config: edit.ack must be full or local, got %q", e.Ack) + } +} diff --git a/internal/config/edit_ack_mode_test.go b/internal/config/edit_ack_mode_test.go new file mode 100644 index 000000000..c0ca884c4 --- /dev/null +++ b/internal/config/edit_ack_mode_test.go @@ -0,0 +1,22 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInvalidEditAckConfigurationRefusesLoading(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("edit:\n ack: immediate\n"), 0o600)) + t.Run("workspace", func(t *testing.T) { + _, err := Load(path) + require.ErrorContains(t, err, "edit.ack") + }) + t.Run("global", func(t *testing.T) { + _, err := LoadGlobal(path) + require.ErrorContains(t, err, "edit.ack") + }) +} diff --git a/internal/config/global.go b/internal/config/global.go index c8ef165a9..1f9d20585 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -68,6 +68,7 @@ type ProjectConfig struct { // GlobalConfig is the user-level config at ~/.gortex/config.yaml. type GlobalConfig struct { + Edit EditConfig `mapstructure:"edit" yaml:"edit,omitempty"` Projects map[string]ProjectConfig `mapstructure:"projects" yaml:"projects,omitempty"` Repos []RepoEntry `mapstructure:"repos" yaml:"repos,omitempty"` ActiveProject string `mapstructure:"active_project" yaml:"active_project,omitempty"` @@ -347,6 +348,9 @@ func LoadGlobal(configPath ...string) (*GlobalConfig, error) { return nil, fmt.Errorf("parsing global config: %w", err) } + if _, err := gc.Edit.AckMode(); err != nil { + return nil, err + } gc.configPath = path return gc, nil } diff --git a/internal/daemon/overlay.go b/internal/daemon/overlay.go index f7e2bce0c..5d9176a98 100644 --- a/internal/daemon/overlay.go +++ b/internal/daemon/overlay.go @@ -853,6 +853,10 @@ type MergeOptions struct { Into string ToDisk bool Force bool + // ExpectedFiles is a bounded source snapshot checked under the branch + // mutex before the first disk callback. Nil retains legacy callers; + // a non-nil empty slice requires an empty source branch. + ExpectedFiles []OverlayFile } // DiskWriteFn is the callback MergeBranches invokes for each From @@ -926,6 +930,17 @@ func (m *OverlayManager) MergeBranches(sessionID string, opts MergeOptions, writ } if opts.ToDisk { + if opts.ExpectedFiles != nil { + if len(opts.ExpectedFiles) != len(src.files) { + return MergeResult{}, errors.New("merge: source branch changed while waiting for disk paths") + } + for _, expected := range opts.ExpectedFiles { + actual, ok := src.files[expected.Path] + if !ok || !overlayFilesEqual(actual, expected) { + return MergeResult{}, errors.New("merge: source branch changed while waiting for disk paths") + } + } + } if write == nil { return MergeResult{}, errors.New("merge: disk write callback is required for to_disk merges") } diff --git a/internal/daemon/runtime_state.go b/internal/daemon/runtime_state.go index 2c69d0b84..3b4626cb6 100644 --- a/internal/daemon/runtime_state.go +++ b/internal/daemon/runtime_state.go @@ -126,6 +126,22 @@ func RemoveRuntimeState() { _ = os.Remove(RuntimeStatePath()) } +// ReadRuntimeStateOf returns the record process pid wrote, whether or not +// pid is still alive. A parent that spawned pid uses it to explain why that +// child exited during startup; the PID binding keeps it from reporting a +// record some other daemon left behind. +func ReadRuntimeStateOf(pid int) (RuntimeState, bool) { + var st RuntimeState + blob, err := os.ReadFile(RuntimeStatePath()) + if err != nil || pid <= 0 { + return RuntimeState{}, false + } + if err := json.Unmarshal(blob, &st); err != nil || st.PID != pid { + return RuntimeState{}, false + } + return st, true +} + // ReadRuntimeState returns the running daemon's recorded state. It reports // false when there is no record, when the record cannot be parsed, or when the // process that wrote it is gone — a record a killed daemon left behind names a diff --git a/internal/daemon/runtime_state_startup_test.go b/internal/daemon/runtime_state_startup_test.go index 729c65904..b3cd61faa 100644 --- a/internal/daemon/runtime_state_startup_test.go +++ b/internal/daemon/runtime_state_startup_test.go @@ -46,3 +46,24 @@ func TestWriteRuntimeStatePublishesWholeJSON(t *testing.T) { } } } + +func TestReadRuntimeStateOfOutlivesItsWriter(t *testing.T) { + path := filepath.Join(t.TempDir(), "daemon.state.json") + t.Setenv("GORTEX_DAEMON_STATEFILE", path) + // No platform hands out a PID this large, so the writer is certainly gone. + const exited = 1 << 30 + record := `{"pid":1073741824,"backend_path":"","startup_phase":"failed","startup_error":"refused"}` + if err := os.WriteFile(path, []byte(record), 0o600); err != nil { + t.Fatal(err) + } + if _, ok := ReadRuntimeState(); ok { + t.Fatal("a dead writer's record must not route live readers") + } + st, ok := ReadRuntimeStateOf(exited) + if !ok || st.StartupPhase != StartupFailed || st.StartupError != "refused" { + t.Fatalf("the spawning parent must still read its child's last record: %+v ok=%v", st, ok) + } + if _, ok := ReadRuntimeStateOf(exited + 1); ok { + t.Fatal("another process's record must not be reported") + } +} diff --git a/internal/daemon/server.go b/internal/daemon/server.go index abfffa527..05bd2e4b7 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -29,6 +29,10 @@ import ( // construction time — the daemon package deliberately doesn't depend // on internal/mcp to keep the direction of imports clean. type Server struct { + runtimeMu sync.RWMutex + runtimeClosing bool + startupMetadata bool + SocketPath string Version string Logger *zap.Logger @@ -476,7 +480,7 @@ func (s *Server) runMaintenance() { return case <-t.C: for _, sd := range s.sessions.SweepDead(platform.ProcessExists) { - if hook, ok := s.MCPDispatcher.(SessionEndedHook); ok && hook != nil { + if hook, ok := s.currentDispatcher().(SessionEndedHook); ok && hook != nil { hook.SessionEnded(sd) } s.Logger.Info("daemon: swept dead session", @@ -534,7 +538,7 @@ func (s *Server) handle(conn net.Conn) { if sess := s.sessions.Remove(conn); sess != nil { // Fire the optional disconnect hook so implementations can // release per-session resources keyed by this ID. - if hook, ok := s.MCPDispatcher.(SessionEndedHook); ok && hook != nil { + if hook, ok := s.currentDispatcher().(SessionEndedHook); ok && hook != nil { hook.SessionEnded(sess) } s.Logger.Debug("daemon: session closed", @@ -618,8 +622,8 @@ func (s *Server) handshake(conn net.Conn, reader *bufio.Reader) (*Session, error } // Stamp warmup state so the client can tell a still-warming daemon from a // ready one. The session is established either way — Warming is advisory. - if s.Ready != nil { - ready, phase := s.Ready() + if readyFn := s.currentReadiness(); readyFn != nil { + ready, phase := readyFn() ack.Warming = !ready ack.WarmupPhase = phase } @@ -640,7 +644,7 @@ func (s *Server) handshake(conn net.Conn, reader *bufio.Reader) (*Session, error // returns the raw reply to write back. Nil reply = no response (the // client sent a notification). func (s *Server) serveMCP(conn net.Conn, reader *bufio.Reader, sess *Session) { - if s.MCPDispatcher == nil { + if s.currentDispatcher() == nil { _ = WriteJSONLine(conn, map[string]any{ "jsonrpc": "2.0", "error": map[string]any{ @@ -652,18 +656,18 @@ func (s *Server) serveMCP(conn net.Conn, reader *bufio.Reader, sess *Session) { return } - classifier, _ := s.MCPDispatcher.(MCPDispatchClassifier) + classifier, _ := s.currentDispatcher().(MCPDispatchClassifier) hooks := &mcpConnectionHooks{ sessionID: sess.ID, classify: classifier, onReady: func(write func([]byte) error) { - if hook, ok := s.MCPDispatcher.(SessionStartedHook); ok && hook != nil { + if hook, ok := s.currentDispatcher().(SessionStartedHook); ok && hook != nil { hook.SessionStarted(sess, write) } }, } serveMCPConnectionWithHooks(conn, reader, s.mcpToolCallTimeout(), func(ctx context.Context, line []byte) ([]byte, error) { - reply, err := DispatchMCPFrame(ctx, sess, line, s.MCPDispatcher) + reply, err := DispatchMCPFrame(ctx, sess, line, s.currentDispatcher()) if err != nil && s.Logger != nil { if ctxErr := ctx.Err(); ctxErr != nil { outcome := "cancelled" @@ -695,7 +699,7 @@ func (s *Server) serveMCP(conn net.Conn, reader *bufio.Reader, sess *Session) { // serveControl drains ControlRequest messages, invokes the Controller, // and writes paired ControlResponse messages. func (s *Server) serveControl(conn net.Conn, reader *bufio.Reader, sess *Session) { - if s.Controller == nil { + if controller, startup := s.currentController(); controller == nil && !startup { _ = WriteJSONLine(conn, ControlResponse{ ErrorCode: ErrInternal, ErrorMsg: "daemon started without controller", @@ -776,6 +780,13 @@ func (s *Server) handleControlBounded(sess *Session, req ControlRequest) Control } func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlRequest) ControlResponse { + controller, startup := s.currentController() + if controller == nil && startup { + if req.Kind == ControlShutdown { + return ControlResponse{OK: true} + } + return ControlResponse{ErrorCode: ErrRepoLoading, ErrorMsg: "store is opening or migrating; retry", Retryable: true} + } switch req.Kind { case ControlTrackPreflight: // Separate from Track so an unsupported/older controller cannot @@ -807,7 +818,7 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque if err := unmarshalParams(req.Params, &p); err != nil { return controlErr(ErrInternal, err.Error()) } - result, err := s.Controller.Track(ctx, p) + result, err := controller.Track(ctx, p) if err != nil { if errors.Is(err, indexer.ErrRepoIdentityPending) { return ControlResponse{ErrorCode: ErrRepoLoading, ErrorMsg: err.Error(), Retryable: true} @@ -825,28 +836,28 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque if err := unmarshalParams(req.Params, &p); err != nil { return controlErr(ErrInternal, err.Error()) } - result, err := s.Controller.Untrack(ctx, p) + result, err := controller.Untrack(ctx, p) if err != nil { return controlErr(ErrInternal, err.Error()) } return ControlResponse{OK: true, Result: result} case ControlProxy: - result, err := s.Controller.ReloadServers(ctx) + result, err := controller.ReloadServers(ctx) if err != nil { return controlErr(ErrInternal, err.Error()) } return ControlResponse{OK: true, Result: result} case ControlReload: - result, err := s.Controller.Reload(ctx) + result, err := controller.Reload(ctx) if err != nil { return controlErr(ErrInternal, err.Error()) } return ControlResponse{OK: true, Result: result} case ControlProbe: - pr, err := s.Controller.Probe(ctx) + pr, err := controller.Probe(ctx) if err != nil { return controlErr(ErrInternal, err.Error()) } @@ -865,12 +876,12 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque // something else entirely, gets the routine counter-backed poll. _ = json.Unmarshal(req.Params, &sp) } - statusFn := s.Controller.Status + statusFn := controller.Status if sp.Exact { // Optional: a controller whose backend cannot recount simply does // not implement this, and --exact degrades to the ordinary answer // rather than failing. - if exact, ok := s.Controller.(StatusExactController); ok { + if exact, ok := controller.(StatusExactController); ok { statusFn = exact.StatusExact } } @@ -923,7 +934,7 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque if err := unmarshalParams(req.Params, &p); err != nil { return controlErr(ErrInternal, err.Error()) } - result, err := s.Controller.SearchSymbols(ctx, p) + result, err := controller.SearchSymbols(ctx, p) if err != nil { return controlErr(ErrInternal, err.Error()) } @@ -934,7 +945,7 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque return ControlResponse{OK: true, Result: buf} case ControlFileCoverage: - coverage, ok := s.Controller.(FileCoverageController) + coverage, ok := controller.(FileCoverageController) if !ok { return controlErr(ErrInternal, "this daemon cannot resolve a path to the view that serves it") } @@ -953,7 +964,7 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque return ControlResponse{OK: true, Result: buf} case ControlDirCoverage: - coverage, ok := s.Controller.(DirCoverageController) + coverage, ok := controller.(DirCoverageController) if !ok { return controlErr(ErrInternal, "this daemon cannot resolve a path to the view that serves it") } @@ -973,7 +984,7 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque case ControlShutdown: resp := ControlResponse{OK: true} - if err := s.Controller.Shutdown(ctx); err != nil { + if err := controller.Shutdown(ctx); err != nil { resp.Warnings = []string{err.Error()} } return resp @@ -983,7 +994,7 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque if err := unmarshalParams(req.Params, &p); err != nil { return controlErr(ErrInternal, err.Error()) } - result, err := s.Controller.EnrichChurn(ctx, p) + result, err := controller.EnrichChurn(ctx, p) if err != nil { return controlErr(ErrInternal, err.Error()) } @@ -998,7 +1009,7 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque if err := unmarshalParams(req.Params, &p); err != nil { return controlErr(ErrInternal, err.Error()) } - result, err := s.Controller.EnrichReleases(ctx, p) + result, err := controller.EnrichReleases(ctx, p) if err != nil { return controlErr(ErrInternal, err.Error()) } @@ -1013,7 +1024,7 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque if err := unmarshalParams(req.Params, &p); err != nil { return controlErr(ErrInternal, err.Error()) } - result, err := s.Controller.EnrichBlame(ctx, p) + result, err := controller.EnrichBlame(ctx, p) if err != nil { return controlErr(ErrInternal, err.Error()) } @@ -1028,7 +1039,7 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque if err := unmarshalParams(req.Params, &p); err != nil { return controlErr(ErrInternal, err.Error()) } - result, err := s.Controller.EnrichCoverage(ctx, p) + result, err := controller.EnrichCoverage(ctx, p) if err != nil { return controlErr(ErrInternal, err.Error()) } @@ -1043,7 +1054,7 @@ func (s *Server) handleControl(ctx context.Context, _ *Session, req ControlReque if err := unmarshalParams(req.Params, &p); err != nil { return controlErr(ErrInternal, err.Error()) } - result, err := s.Controller.EnrichCochange(ctx, p) + result, err := controller.EnrichCochange(ctx, p) if err != nil { return controlErr(ErrInternal, err.Error()) } @@ -1067,11 +1078,12 @@ func (s *Server) Shutdown() error { } // No producer callback runs under the once/admission mutex. Repeated // callers cancel and join the same immutable writer lifetime. - if s.CancelShutdownOwners != nil { - s.CancelShutdownOwners() + cancelOwners, joinWriter := s.closeRuntimeAdmission() + if cancelOwners != nil { + cancelOwners() } - if s.JoinRuntimeWriter != nil { - s.JoinRuntimeWriter() + if joinWriter != nil { + joinWriter() } ran := false s.doneOnce.Do(func() { diff --git a/internal/daemon/startup_publish.go b/internal/daemon/startup_publish.go new file mode 100644 index 000000000..553c010db --- /dev/null +++ b/internal/daemon/startup_publish.go @@ -0,0 +1,65 @@ +package daemon + +import "errors" + +// EnableStartupMetadata allows the existing socket owner to serve connection +// metadata before a store/controller exists. Configure it before Listen. +func (s *Server) EnableStartupMetadata() { s.startupMetadata = true } + +// PublishStartupRuntime is the single store-open handoff. The same listener +// and accepted connections survive it. Shutdown fences publication before it +// calls owners, so no completed store can be attached after close admission. +func (s *Server) PublishStartupRuntime(controller Controller, dispatcher MCPDispatcher, ready func() (bool, string), cancel, join func(), statePath string) error { + s.runtimeMu.Lock() + defer s.runtimeMu.Unlock() + if s.runtimeClosing { + return errors.New("daemon: startup publication refused after shutdown") + } + if !s.startupMetadata { + return errors.New("daemon: startup runtime is already published") + } + s.Controller, s.MCPDispatcher, s.Ready = controller, dispatcher, ready + s.CancelShutdownOwners, s.JoinRuntimeWriter = cancel, join + // Before this handoff the startup reporter owns its failure record. Keep + // that record available to the spawning CLI if opening/migration fails. + s.statePath = statePath + s.startupMetadata = false + return nil +} + +func (s *Server) StartupClosed() bool { + s.runtimeMu.RLock() + defer s.runtimeMu.RUnlock() + return s.runtimeClosing +} + +func (s *Server) currentDispatcher() MCPDispatcher { + s.runtimeMu.RLock() + defer s.runtimeMu.RUnlock() + return s.MCPDispatcher +} + +func (s *Server) currentController() (Controller, bool) { + s.runtimeMu.RLock() + defer s.runtimeMu.RUnlock() + return s.Controller, s.startupMetadata +} + +func (s *Server) currentReadiness() func() (bool, string) { + s.runtimeMu.RLock() + defer s.runtimeMu.RUnlock() + return s.Ready +} + +func (s *Server) closeRuntimeAdmission() (func(), func()) { + s.runtimeMu.Lock() + defer s.runtimeMu.Unlock() + s.runtimeClosing = true + return s.CancelShutdownOwners, s.JoinRuntimeWriter +} + +func (s *Server) StartupPublished() bool { + s.runtimeMu.RLock() + defer s.runtimeMu.RUnlock() + return !s.startupMetadata && !s.runtimeClosing +} diff --git a/internal/daemon/startup_publish_test.go b/internal/daemon/startup_publish_test.go new file mode 100644 index 000000000..c259cae30 --- /dev/null +++ b/internal/daemon/startup_publish_test.go @@ -0,0 +1,33 @@ +package daemon + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestStartupMetadataControlDoesNotRequireController(t *testing.T) { + s := New("unused", "test", zap.NewNop()) + s.EnableStartupMetadata() + reply := s.handleControl(context.Background(), nil, ControlRequest{Kind: ControlSearchSymbols}) + require.Equal(t, ErrRepoLoading, reply.ErrorCode) + require.True(t, reply.Retryable) + require.True(t, s.handleControl(context.Background(), nil, ControlRequest{Kind: ControlShutdown}).OK) + require.NoError(t, s.Shutdown()) +} + +func TestStartupRuntimePublicationIsFencedBeforeOwnerJoin(t *testing.T) { + s := New("unused", "test", zap.NewNop()) + s.EnableStartupMetadata() + entered, release := make(chan struct{}), make(chan struct{}) + s.CancelShutdownOwners = func() { close(entered); <-release } + done := make(chan error, 1) + go func() { done <- s.Shutdown() }() + <-entered + require.Error(t, s.PublishStartupRuntime(nil, nil, nil, nil, nil, ""), "close admission precedes a potentially slow owner join") + close(release) + require.NoError(t, <-done) + require.True(t, s.StartupClosed()) +} diff --git a/internal/graph/store_sqlite/edit_gate_seed_disabled.go b/internal/graph/store_sqlite/edit_gate_seed_disabled.go new file mode 100644 index 000000000..34e0d6b2a --- /dev/null +++ b/internal/graph/store_sqlite/edit_gate_seed_disabled.go @@ -0,0 +1,6 @@ +//go:build !gortex_edit_gate_seed + +package store_sqlite + +// The deliberately bad reclaim policy is absent from production builds. +func editGateClosedReclaimSeed(*Store) {} diff --git a/internal/graph/store_sqlite/edit_gate_seed_disabled_test.go b/internal/graph/store_sqlite/edit_gate_seed_disabled_test.go new file mode 100644 index 000000000..2bcb8a7ec --- /dev/null +++ b/internal/graph/store_sqlite/edit_gate_seed_disabled_test.go @@ -0,0 +1,21 @@ +//go:build !gortex_edit_gate_seed + +package store_sqlite + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEditGateProductionBuildCannotEnableClosedSeed(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "closed-read-gate") + s, err := openPristine(t, filepath.Join(t.TempDir(), "production.sqlite")) + require.NoError(t, err) + defer s.Close() + before := s.WALReclaimStats() + release := s.AnnounceWrite() + defer release() + require.Equal(t, before.PauseCount, s.WALReclaimStats().PauseCount, "runtime environment cannot activate the test-only closed-gate policy") +} diff --git a/internal/graph/store_sqlite/edit_gate_seed_enabled.go b/internal/graph/store_sqlite/edit_gate_seed_enabled.go new file mode 100644 index 000000000..a5f8d1d06 --- /dev/null +++ b/internal/graph/store_sqlite/edit_gate_seed_enabled.go @@ -0,0 +1,33 @@ +//go:build gortex_edit_gate_seed + +package store_sqlite + +import ( + "context" + "os" + "time" +) + +func editGateClosedReclaimSeed(s *Store) { + if os.Getenv("GORTEX_EDIT_GATE_SEED") != "closed-read-gate" || s.coreless() || s.readGate == nil { + return + } + db, err := openWALReclaimCheckpointDB(s.dbPath) + if err != nil { + return + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + cfg := resolveWALReclaimConfig() + // Force the existing closed-admission path even with a small/empty log; + // no WAL size or counter sample is fabricated by this selection fault. + cfg.readerWait, cfg.thresholdBytes = 0, -1 + res := walReclaimResult{urgent: true, bytesBefore: s.observedWALFileSize(s.dbPath + "-wal")} + _ = s.reclaimWALInLane(ctx, cfg, db, &res) + if res.pauseClosed { + s.walReclaim.update(func(stats *WALReclaimStats) { stats.notePause(res.pause) }) + s.walReclaim.pauseDrain.observe(res.pauseDrain) + s.walReclaim.pauseTruncate.observe(res.pauseTruncate) + } +} diff --git a/internal/graph/store_sqlite/edit_gate_seed_enabled_test.go b/internal/graph/store_sqlite/edit_gate_seed_enabled_test.go new file mode 100644 index 000000000..11e3f99f7 --- /dev/null +++ b/internal/graph/store_sqlite/edit_gate_seed_enabled_test.go @@ -0,0 +1,23 @@ +//go:build gortex_edit_gate_seed + +package store_sqlite + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEditGateClosedSeedUsesRealAdmissionAndPauseCounter(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "closed-read-gate") + s, err := openPristine(t, filepath.Join(t.TempDir(), "seed.sqlite")) + require.NoError(t, err) + defer s.Close() + before := s.WALReclaimStats() + release := s.AnnounceWrite() + defer release() + require.True(t, s.editIntentActive(), "the forced reclaim belongs to an actual admitted write announcement") + after := s.WALReclaimStats() + require.Greater(t, after.PauseCount, before.PauseCount, "test-only policy must actually close read admission, not synthesize a report value") +} diff --git a/internal/graph/store_sqlite/planner_stats_freshness_test.go b/internal/graph/store_sqlite/planner_stats_freshness_test.go index dd2f060dc..8c2eaad64 100644 --- a/internal/graph/store_sqlite/planner_stats_freshness_test.go +++ b/internal/graph/store_sqlite/planner_stats_freshness_test.go @@ -320,13 +320,14 @@ func TestEnsurePlannerStatsFresh_RecyclesReadPool(t *testing.T) { seedNamedGoReceiverFixture(s, "b", 150) writeIndexStateCounters(t, s, 0, "repo", 500, 250) + before := warmPlannerReadPool(t, s) h := mustEnsure(t, s) if !h.Refreshed { t.Fatalf("grown store did not refresh: reason=%q", h.Reason) } stats := s.db.Stats() - if stats.MaxIdleClosed == 0 { - t.Errorf("read pool kept its pre-refresh connections (MaxIdleClosed=0, Idle=%d); "+ + if stats.MaxIdleClosed <= before { + t.Errorf("read pool kept its pre-refresh connections (no new idle close, Idle=%d); "+ "they would plan against the pre-growth statistics for the life of the store", stats.Idle) } if stats.Idle != 0 { @@ -334,18 +335,77 @@ func TestEnsurePlannerStatsFresh_RecyclesReadPool(t *testing.T) { } }) - t.Run("fresh", func(t *testing.T) { - s := freshStatsStore(t, "stats_recycle_fresh.sqlite", 100) + for _, priorClose := range []bool{false, true} { + t.Run(fmt.Sprintf("fresh/prior_close=%t", priorClose), func(t *testing.T) { + s := freshStatsStore(t, "stats_recycle_fresh.sqlite", 100) + warmPlannerReadPool(t, s) + if priorClose { + before := s.db.Stats().MaxIdleClosed + s.db.SetMaxIdleConns(0) + s.db.SetMaxIdleConns(1) + if s.db.Stats().MaxIdleClosed != before+1 { + t.Fatal("prior-close control did not close exactly one warm idle connection") + } + if err := s.db.Ping(); err != nil { + t.Fatal(err) + } + } + before := warmPlannerReadPool(t, s) + if h := mustEnsure(t, s); h.Refreshed { + t.Fatalf("fresh store refreshed: reason=%q", h.Reason) + } + if stats := s.db.Stats(); stats.MaxIdleClosed != before { + t.Errorf("fresh store recycled the read pool (idle closes before=%d after=%d); the recycle is only "+ + "owed by a store that actually rewrote its statistics", before, stats.MaxIdleClosed) + } + }) + } + t.Run("fresh_oracle_rejects_real_recycle", func(t *testing.T) { + s := freshStatsStore(t, "stats_recycle_negative.sqlite", 100) + before := warmPlannerReadPool(t, s) if h := mustEnsure(t, s); h.Refreshed { - t.Fatalf("fresh store refreshed: reason=%q", h.Reason) + t.Fatalf("fresh negative-control store refreshed: reason=%q", h.Reason) } - if stats := s.db.Stats(); stats.MaxIdleClosed != 0 { - t.Errorf("fresh store recycled the read pool (MaxIdleClosed=%d); the recycle is only "+ - "owed by a store that actually rewrote its statistics", stats.MaxIdleClosed) + // Semantic negative: the same zero-delta predicate used above MUST + // reject an actual recycle of the controlled warm read connection. + recycleStatsReadPool(s.db, s.writerDB) + if after := s.db.Stats(); after.MaxIdleClosed != before+1 || after.Idle != 0 { + t.Fatalf("real recycle not detected: before=%d after=%+v", before, after) } }) } +// warmPlannerReadPool excludes ordinary surplus-idle closure from the measured +// interval: one physical read connection may exist, and its idle slot is kept. +// The returned counter is captured only after setup has returned that connection. +func warmPlannerReadPool(t *testing.T, s *Store) int64 { + t.Helper() + s.stopCheckpointLoop() + s.stopMaintenanceLane() + // stopMaintenanceLane has a bounded wait. Refuse to observe the pool + // unless its actual worker has finished, rather than assuming a join. + s.maintenanceSched.Lock() + done := s.maintenanceDone + s.maintenanceSched.Unlock() + if done != nil { + select { + case <-done: + default: + t.Fatal("maintenance worker has not finished before pool observation") + } + } + s.db.SetMaxOpenConns(1) + s.db.SetMaxIdleConns(1) + if err := s.db.Ping(); err != nil { + t.Fatal(err) + } + stats := s.db.Stats() + if stats.InUse != 0 || stats.Idle != 1 || stats.OpenConnections != 1 { + t.Fatalf("read pool is not a single warm idle connection: %+v", stats) + } + return stats.MaxIdleClosed +} + // The payload index_health publishes is read straight off this struct, so what // each field means has to be pinned: believed comes from the named index's // stat row (with the documented nodes_by_file fallback), actual from the diff --git a/internal/graph/store_sqlite/purge_cancel_test.go b/internal/graph/store_sqlite/purge_cancel_test.go new file mode 100644 index 000000000..25cf17cc6 --- /dev/null +++ b/internal/graph/store_sqlite/purge_cancel_test.go @@ -0,0 +1,112 @@ +package store_sqlite + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPurgeRepoCancellationReleasesQueuedWriter(t *testing.T) { + s := openPurgeStore(t) + seedRepoRows(t, s.writerDB, "gone") + s.writeMu.Lock() + locked := true + unlock := func() { + if locked { + s.writeMu.Unlock() + locked = false + } + } + defer unlock() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- s.PurgeRepoContext(ctx, "gone") }() + // Observe actual writer admission rather than canceling before the call. + deadline := time.Now().Add(time.Second) + for s.writeMu.waiting() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + require.Positive(t, s.writeMu.waiting()) + cancel() + var result error + var joined bool + select { + case result = <-done: + joined = true + case <-time.After(200 * time.Millisecond): + } + // Cleanup always joins actual work before Close, even on the expected RED. + unlock() + if !joined { + select { + case result = <-done: + case <-time.After(2 * time.Second): + t.Fatal("tiny purge failed to join after releasing its owned gate") + } + } + require.True(t, joined, "canceled purge remained queued until an unrelated writer released the gate") + require.ErrorIs(t, result, context.Canceled) + require.Equal(t, 1, countByPrefix(t, s.db, "nodes", "gone"), "canceled writer admission must preserve the orphan row") +} + +func TestPurgeRepoCancellationReleasesPreflightConnectionWait(t *testing.T) { + s := openPurgeStore(t) + seedRepoRows(t, s.writerDB, "gone") + s.writerDB.SetMaxOpenConns(1) + conn, err := s.writerDB.Conn(context.Background()) + require.NoError(t, err) + defer conn.Close() + before := s.writerDB.Stats().WaitCount + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- s.PurgeRepoContext(ctx, "gone") }() + require.Eventually(t, func() bool { return s.writerDB.Stats().WaitCount > before }, time.Second, time.Millisecond, "purge must actually wait for its owned preflight connection") + cancel() + select { + case err = <-done: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("canceled purge did not release its preflight connection wait") + } + require.NoError(t, conn.Close()) + require.False(t, s.writeMu.held(), "canceled maintenance must release the writer before returning") + require.Equal(t, 1, countByPrefix(t, s.db, "nodes", "gone")) +} + +func TestPurgeRepoCancellationRollsBackSQLBeforeWriterRelease(t *testing.T) { + installShadowDeleteFunction(t) + s := openPurgeStore(t) + seedRepoRows(t, s.writerDB, "gone") + shadowDeleteTrigger(t, s, "nodes", "OLD.repo_prefix") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var reached atomic.Bool + // The trigger runs inside the actual final DELETE, after edges and all + // sidecar deletes. Cancellation must roll back that real partial transaction. + shadowDeleteObserver.Store(&shadowDeleteProbe{fire: func(prefix string) { + if prefix == "gone" { + reached.Store(true) + cancel() + } + }}) + err := s.PurgeRepoContext(ctx, "gone") + require.True(t, reached.Load(), "test must cancel during actual purge SQL, not before admission") + require.ErrorIs(t, err, context.Canceled) + require.False(t, s.writeMu.held()) + require.Equal(t, 1, countByPrefix(t, s.db, "nodes", "gone")) + for _, table := range prefixKeyedTables { + require.Positive(t, countByPrefix(t, s.db, table, "gone"), "canceled purge must restore %s", table) + } + var edges int + require.NoError(t, s.db.QueryRow(`SELECT COUNT(*) FROM edges WHERE from_id = 'gone::a.go::X'`).Scan(&edges)) + require.Equal(t, 1, edges) + _, err = s.writerDB.Exec(`DROP TRIGGER test_shadow_delete`) + require.NoError(t, err) + require.NoError(t, s.PurgeRepo("gone"), "joined rollback must leave the writer usable") + require.Zero(t, countByPrefix(t, s.db, "nodes", "gone")) +} diff --git a/internal/graph/store_sqlite/retirement_wal_wait.go b/internal/graph/store_sqlite/retirement_wal_wait.go index aa73b6c0d..f1b7ecdbb 100644 --- a/internal/graph/store_sqlite/retirement_wal_wait.go +++ b/internal/graph/store_sqlite/retirement_wal_wait.go @@ -95,20 +95,53 @@ func (s *Store) awaitWALUnderCeiling(ctx context.Context, generationID int64, ep if s.buildLaneBusy() || s.walCopy.editing(time.Now()) { waitMax = retirementWALWaitEditingMax } - deadline := time.NewTimer(waitMax) - defer deadline.Stop() + if err := s.waitForWALReclaim(ctx, waitMax, func() bool { return s.retirementLogBytes() < ceiling }); err != nil && ctx.Err() == nil { + s.walReclaim.cycle.retirementTimeouts.Add(1) + } +} + +// AwaitWarmupWAL is the producer boundary for bounded startup-restage chunks. +// It holds no writer or reader while the existing reclaim makes progress. +// Unlike retirement's best-effort wait, refusal stops the next warmup chunk. +// A reset allocation below half the ceiling remains reusable; a logical reset +// alone cannot admit a physical file that has consumed that chunk headroom. +// This early pressure boundary is not an arbitrary-transaction byte quota. +func (s *Store) AwaitWarmupWAL(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + threshold, ceiling := s.walReclaimBounds() + mark := retirementWALMark(threshold, ceiling) + if mark <= 0 || s.dbPath == "" { + return nil + } + ready := func() bool { + return s.retirementLogBytes() < mark && walFileSize(s.dbPath+"-wal") <= ceiling/2 + } + if ready() { + return nil + } + s.warmupWALWaiters.Add(1) + defer s.warmupWALWaiters.Add(-1) + return s.waitForWALReclaim(ctx, retirementWALWaitMax, ready) +} + +func (s *Store) waitForWALReclaim(ctx context.Context, maxWait time.Duration, ready func() bool) error { + if ready() { + return nil + } + waitCtx, cancel := context.WithTimeout(ctx, maxWait) + defer cancel() + s.RequestWALReclaim() poll := time.NewTicker(retirementWALWaitPoll) defer poll.Stop() for { select { - case <-ctx.Done(): - return - case <-deadline.C: - s.walReclaim.cycle.retirementTimeouts.Add(1) - return + case <-waitCtx.Done(): + return waitCtx.Err() case <-poll.C: - if s.retirementLogBytes() < ceiling { - return + if ready() { + return nil } s.RequestWALReclaim() } diff --git a/internal/graph/store_sqlite/schema_version.go b/internal/graph/store_sqlite/schema_version.go index 84dd0a250..c8501449b 100644 --- a/internal/graph/store_sqlite/schema_version.go +++ b/internal/graph/store_sqlite/schema_version.go @@ -64,6 +64,14 @@ const ( MigrationStarted MigrationPhase = "started" MigrationFinished MigrationPhase = "finished" MigrationFailed MigrationPhase = "failed" + + // The backup phases report the pre-upgrade backup Open takes under + // WithUpgradeBackup, once, before the first step starts. Version is the + // schema version being backed up and Detail carries the backup path + // (created, kept) or the reason none was taken (skipped). + MigrationBackupCreated MigrationPhase = "backup_created" + MigrationBackupKept MigrationPhase = "backup_kept" + MigrationBackupSkipped MigrationPhase = "backup_skipped" ) // MigrationProgress describes one schema migration step. Elapsed is measured @@ -76,6 +84,7 @@ type MigrationProgress struct { Phase MigrationPhase Elapsed time.Duration Error error + Detail string } // MigrationObserver receives synchronous migration boundaries. It may be nil. @@ -1387,15 +1396,50 @@ func setUserVersion(db *sql.DB, v int) error { return nil } +// upgradePoint names a boundary inside a schema transition at which the +// in-package crash tests stop the process, to prove the next Open completes +// the transition from whatever a kill there leaves on disk. +type upgradePoint string + +const ( + // upgradeInStep follows a step body inside the uncommitted migration + // transaction; version is that step's. + upgradeInStep upgradePoint = "in_step" + // upgradeIndexRecreation falls between the core and the sidecar index + // rebuilds, after the migration transaction has committed. + upgradeIndexRecreation upgradePoint = "index_recreation" + // upgradeBeforeStamp is the last moment before user_version is written. + upgradeBeforeStamp upgradePoint = "before_stamp" + // upgradeBackupCopied follows the pre-upgrade copy into its partial file, + // before the copy is folded and renamed into place. + upgradeBackupCopied upgradePoint = "backup_copied" +) + +// upgradeHook is called synchronously at each upgradePoint. Only tests set +// one; a nil hook is a no-op. +type upgradeHook func(point upgradePoint, version int) + +func (h upgradeHook) at(point upgradePoint, version int) { + if h != nil { + h(point, version) + } +} + // applyInPlaceMigrations runs the in-place steps in a single transaction. func applyInPlaceMigrations(db *sql.DB, steps []schemaMigration, observers ...MigrationObserver) error { - if len(steps) == 0 { - return nil - } var observe MigrationObserver if len(observers) > 0 { observe = observers[0] } + return applyInPlaceMigrationsWith(db, steps, observe, nil) +} + +// applyInPlaceMigrationsWith is applyInPlaceMigrations with the crash-test +// hook threaded through. +func applyInPlaceMigrationsWith(db *sql.DB, steps []schemaMigration, observe MigrationObserver, hook upgradeHook) error { + if len(steps) == 0 { + return nil + } tx, err := db.Begin() if err != nil { return err @@ -1418,6 +1462,7 @@ func applyInPlaceMigrations(db *sql.DB, steps []schemaMigration, observers ...Mi } return fmt.Errorf("schema migration v%d (%s): %w", m.version, m.name, err) } + hook.at(upgradeInStep, m.version) if observe != nil { observe(MigrationProgress{ Version: m.version, diff --git a/internal/graph/store_sqlite/schema_version_downgrade.go b/internal/graph/store_sqlite/schema_version_downgrade.go index f86cbaf9b..64614ed33 100644 --- a/internal/graph/store_sqlite/schema_version_downgrade.go +++ b/internal/graph/store_sqlite/schema_version_downgrade.go @@ -35,8 +35,39 @@ func (e *SchemaTooNewError) Unwrap() error { return ErrSchemaTooNew } // the second check prevents a rebuild, but cannot undo writer PRAGMAs if another // process swaps or upgrades the database after this read-only probe. func checkSchemaDowngrade(path string, supported int) error { + probe, err := probeStoreSchema(path) + if err != nil { + return err + } + return probe.refuseNewer(supported) +} + +// storeProbe is what the read-only schema probe learned about an on-disk +// store before Open reaches it with a writer connection. The zero value +// describes an in-memory database or a file that does not exist yet. +type storeProbe struct { + filename string // main database file; empty for an in-memory database + uri string // read-only, WAL-aware URI over filename + exists bool // filename was present when the probe ran + stored int // PRAGMA user_version, including a version held in WAL +} + +// refuseNewer rejects a stored schema this binary cannot interpret. +func (p storeProbe) refuseNewer(supported int) error { + if p.exists && p.stored > supported { + return &SchemaTooNewError{Stored: p.stored, Supported: supported} + } + return nil +} + +// probeStoreSchema reads the stored schema version of the database at path +// through a read-only connection, so nothing about the file (journal mode, +// WAL contents, size) changes. Open uses the result to refuse a newer schema +// and to size a pending upgrade before its writer connection exists. +func probeStoreSchema(path string) (storeProbe, error) { + var probe storeProbe if path == ":memory:" { - return nil + return probe, nil } var uri *url.URL var filename string @@ -44,26 +75,26 @@ func checkSchemaDowngrade(path string, supported int) error { var err error uri, err = url.Parse(path) if err != nil { - return fmt.Errorf("sqlite schema probe URI: %w", err) + return storeProbe{}, fmt.Errorf("sqlite schema probe URI: %w", err) } query, err := url.ParseQuery(uri.RawQuery) if err != nil { - return fmt.Errorf("sqlite schema probe query: %w", err) + return storeProbe{}, fmt.Errorf("sqlite schema probe query: %w", err) } if len(query["mode"]) > 1 { - return errors.New("sqlite schema probe: duplicate mode parameters") + return storeProbe{}, errors.New("sqlite schema probe: duplicate mode parameters") } if uri.Opaque == ":memory:" || query.Get("mode") == "memory" { - return nil + return probe, nil } if uri.Host != "" && uri.Host != "localhost" { - return fmt.Errorf("sqlite schema probe: unsupported file authority %q", uri.Host) + return storeProbe{}, fmt.Errorf("sqlite schema probe: unsupported file authority %q", uri.Host) } filename = uri.Path if uri.Opaque != "" { filename, err = url.PathUnescape(uri.Opaque) if err != nil { - return fmt.Errorf("sqlite schema probe path: %w", err) + return storeProbe{}, fmt.Errorf("sqlite schema probe path: %w", err) } } if trimmed := strings.TrimPrefix(filename, "/"); filepath.VolumeName(trimmed) != "" { @@ -82,7 +113,7 @@ func checkSchemaDowngrade(path string, supported int) error { } else { absolute, err := filepath.Abs(path) if err != nil { - return fmt.Errorf("sqlite schema probe path: %w", err) + return storeProbe{}, fmt.Errorf("sqlite schema probe path: %w", err) } filename = absolute slashed := filepath.ToSlash(absolute) @@ -91,25 +122,25 @@ func checkSchemaDowngrade(path string, supported int) error { } uri = &url.URL{Scheme: "file", Path: slashed, RawQuery: "mode=ro&_pragma=busy_timeout(5000)"} } + uri.Fragment = "" + probe.filename = filename + probe.uri = uri.String() if _, err := os.Stat(filename); err != nil { if os.IsNotExist(err) { - return nil // A new store has no on-disk schema to reject. + return probe, nil // A new store has no on-disk schema to reject. } - return fmt.Errorf("sqlite schema probe stat: %w", err) + return storeProbe{}, fmt.Errorf("sqlite schema probe stat: %w", err) } - uri.Fragment = "" - db, err := openSQLiteStoreDB(uri.String()) + probe.exists = true + db, err := openSQLiteStoreDB(probe.uri) if err != nil { - return fmt.Errorf("sqlite schema probe open: %w", err) + return storeProbe{}, fmt.Errorf("sqlite schema probe open: %w", err) } db.SetMaxOpenConns(1) defer db.Close() - stored, err := readUserVersion(db) + probe.stored, err = readUserVersion(db) if err != nil { - return fmt.Errorf("sqlite schema probe version: %w", err) - } - if stored > supported { - return &SchemaTooNewError{Stored: stored, Supported: supported} + return storeProbe{}, fmt.Errorf("sqlite schema probe version: %w", err) } - return nil + return probe, nil } diff --git a/internal/graph/store_sqlite/sqlite_sleep_gauge_test.go b/internal/graph/store_sqlite/sqlite_sleep_gauge_test.go index a0a3a74e9..65c7b4154 100644 --- a/internal/graph/store_sqlite/sqlite_sleep_gauge_test.go +++ b/internal/graph/store_sqlite/sqlite_sleep_gauge_test.go @@ -51,7 +51,10 @@ func TestSQLiteSleepGaugeCountsBusyHandlerSleeps(t *testing.T) { require.Positive(t, split.BusySleeps) require.GreaterOrEqual(t, split.BusySleep, 150*time.Millisecond, "the busy handler's sleep was not counted") require.Positive(t, split.ReadTxns) - require.Positive(t, split.ReadTxn) + // Windows can finish every short transaction in the same wall-clock + // cell. Completed transaction count above proves real read work; only + // the deliberately blocked busy-handler interval has a positive floor. + require.GreaterOrEqual(t, split.ReadTxn, time.Duration(0)) } // The wrapper classifies whole-millisecond delays as the busy handler's and @@ -67,3 +70,11 @@ func TestSQLiteSleepGaugeClassifiesDelays(t *testing.T) { require.Equal(t, int64(2), after.WALRetryCalls-before.WALRetryCalls) require.GreaterOrEqual(t, after.BusyNanos-before.BusyNanos, int64(time.Millisecond)) } + +func TestReaderWaitSplitKeepsCompletedReadsAtZeroClockPrecision(t *testing.T) { + before := ReaderWaitMark{ReadTxns: 7, ReadTxnNanos: 1000} + after := ReaderWaitMark{ReadTxns: 10, ReadTxnNanos: 1000} + split := after.Split(before) + require.EqualValues(t, 3, split.ReadTxns) + require.Zero(t, split.ReadTxn, "equal clock cells remain measured zero, not fabricated positive time") +} diff --git a/internal/graph/store_sqlite/store.go b/internal/graph/store_sqlite/store.go index 986985b9f..ef71881e7 100644 --- a/internal/graph/store_sqlite/store.go +++ b/internal/graph/store_sqlite/store.go @@ -86,6 +86,9 @@ type storeCore struct { // walShrinkPending: the WAL reclaim reset a big log in place and its // file still has to be shrunk in slices (wal_shrink.go). walShrinkPending atomic.Bool + // A blocked warm producer can own the build lane while awaiting shrink. + // Its scoped wait permits shrink through that lane; writer demand still wins. + warmupWALWaiters atomic.Int64 // walLimitKept: an in-place reset gave up on this fully copied log and // left the writer's journal_size_limit lifted; the shrink restores it // once the log has restarted (wal_shrink.go). @@ -748,7 +751,7 @@ func Open(path string, opts ...Option) (*Store, error) { for _, opt := range opts { opt(&o) } - return openWithObserver(path, currentSchemaVersion, schemaMigrations, o.allowRebuild, o.migrationObserver) + return openStore(path, currentSchemaVersion, schemaMigrations, o) } // Option configures Open. @@ -757,6 +760,11 @@ type Option func(*openOptions) type openOptions struct { allowRebuild bool migrationObserver MigrationObserver + upgradeBackup bool + upgradeHook upgradeHook + // diskAvail reports the free bytes on the volume holding a directory. + // nil means platform.DiskAvailBytes; tests inject a fixed figure. + diskAvail func(dir string) (uint64, error) } // WithRebuild permits Open to drop and recreate an on-disk database whose @@ -779,6 +787,28 @@ func WithMigrationObserver(observer MigrationObserver) Option { return func(o *openOptions) { o.migrationObserver = observer } } +// WithUpgradeBackup makes Open copy an existing store to +// UpgradeBackupPath(path, oldVersion) before the first pending in-place +// schema step runs, when the volume has room for the copy on top of the +// upgrade itself. Without room, or when the copy fails, the backup is +// skipped and the reason reported to the migration observer (or the log); +// the upgrade still runs. An existing backup of the same version is kept. +// +// Like WithRebuild, the caller MUST hold exclusive cross-process access to +// the store: the copy is consistent only because no other process can write +// the store while it is taken. +func WithUpgradeBackup() Option { return func(o *openOptions) { o.upgradeBackup = true } } + +// withUpgradeHook installs the crash-test hook on the schema transition. +func withUpgradeHook(hook upgradeHook) Option { + return func(o *openOptions) { o.upgradeHook = hook } +} + +// withDiskAvail replaces the free-space probe the upgrade precheck uses. +func withDiskAvail(fn func(dir string) (uint64, error)) Option { + return func(o *openOptions) { o.diskAvail = fn } +} + // ErrSchemaRebuildRequired is returned by Open when an on-disk database needs a // destructive rebuild but the caller did not pass WithRebuild (i.e. cannot // prove it holds the store lock). @@ -809,15 +839,38 @@ func configureConnectionPool(db *sql.DB) { } func openWith(path string, current int, migrations []schemaMigration, allowRebuild bool) (*Store, error) { - return openWithObserver(path, current, migrations, allowRebuild, nil) + return openStore(path, current, migrations, openOptions{allowRebuild: allowRebuild}) } func openWithObserver(path string, current int, migrations []schemaMigration, allowRebuild bool, observe MigrationObserver) (*Store, error) { + return openStore(path, current, migrations, openOptions{allowRebuild: allowRebuild, migrationObserver: observe}) +} + +// openStore is Open with the target version, the migration registry and the +// resolved options spelled out, so tests can drive any transition without +// mutating package globals. +func openStore(path string, current int, migrations []schemaMigration, o openOptions) (*Store, error) { + allowRebuild, observe, hook := o.allowRebuild, o.migrationObserver, o.upgradeHook // Refuse unsupported newer schemas before a writer connection can change // journal mode or checkpoint their WAL. WithRebuild does not override this. - if err := checkSchemaDowngrade(path, current); err != nil { + probe, err := probeStoreSchema(path) + if err != nil { return nil, err } + if err := probe.refuseNewer(current); err != nil { + return nil, err + } + // Size a pending in-place upgrade while the store is still untouched: a + // refusal here leaves the file byte-identical. The writer handle below + // re-plans from its own read of the version; this plan only sizes. + if space, pending := measureUpgradeSpace(probe, current, planSchemaMigrationWith(probe.stored, current, migrations), o.diskAvail); pending { + if err := space.refuseShort(); err != nil { + return nil, err + } + if o.upgradeBackup { + backupBeforeUpgrade(space, observe, hook) + } + } // Pragmas: WAL + synchronous=NORMAL is the standard write-heavy // embedded tradeoff. cache_size(-32768) gives each pooled connection a @@ -960,7 +1013,7 @@ func openWithObserver(path string, current int, migrations []schemaMigration, al // pass WithRebuild. A wipe plan carries no in-place steps, and after a wipe // the store is empty and the daemon's normal indexing repopulates it. if plan.stamp { - if err := applyInPlaceMigrations(db, plan.inPlace, observe); err != nil { + if err := applyInPlaceMigrationsWith(db, plan.inPlace, observe, hook); err != nil { _ = db.Close() return nil, fmt.Errorf("sqlite schema migrate: %w", err) } @@ -975,6 +1028,9 @@ func openWithObserver(path string, current int, migrations []schemaMigration, al _ = db.Close() return nil, fmt.Errorf("sqlite core index: %w", err) } + if plan.stamp { + hook.at(upgradeIndexRecreation, current) + } if err := createSidecarIndexes(db); err != nil { _ = db.Close() return nil, fmt.Errorf("sqlite sidecar index: %w", err) @@ -996,6 +1052,7 @@ func openWithObserver(path string, current int, migrations []schemaMigration, al // preceding operation is idempotent, so an interruption before this point // leaves the old version and safely retries the transition on the next Open. if plan.stamp { + hook.at(upgradeBeforeStamp, current) if err := setUserVersion(db, current); err != nil { _ = db.Close() return nil, fmt.Errorf("sqlite stamp schema version: %w", err) diff --git a/internal/graph/store_sqlite/store_purge.go b/internal/graph/store_sqlite/store_purge.go index 72ff63704..652778120 100644 --- a/internal/graph/store_sqlite/store_purge.go +++ b/internal/graph/store_sqlite/store_purge.go @@ -71,56 +71,67 @@ var purgeSidecarTables = []string{ // Refuses prefix=="" (shared global externals / solo-mode live data — see the // file-level INVARIANT). func (s *Store) PurgeRepo(prefix string) error { + return s.PurgeRepoContext(context.Background(), prefix) +} + +// PurgeRepoContext preserves the maintenance producer lifetime through writer +// admission, SQL work and commit. Rollback joins before the writer is released. +func (s *Store) PurgeRepoContext(ctx context.Context, prefix string) (err error) { if prefix == "" { return fmt.Errorf("store_sqlite: PurgeRepo refuses empty repo prefix (would delete shared global externals / solo-repo data)") } // The sweep is generation-unscoped; invalidate the base so every view's // analysis and process-local mutation witness observes the removal. if s.viewGen != baseViewGeneration { - return s.atBase().PurgeRepo(prefix) + return s.atBase().PurgeRepoContext(ctx, prefix) } - s.writeMu.Lock() + if err := s.writeMu.LockContext(ctx); err != nil { + return err + } defer s.writeMu.Unlock() // PurgeRepo bypasses the ordinary Add/Evict entry points, so invalidate a // persisted whole-graph analysis before the transaction can delete live graph // rows. The preflight is safe under writeMu and avoids touching analysis state // for sidecar-only purges. - conn, release, err := s.activeWriteConnLocked(context.Background()) + conn, release, err := s.activeWriteConnLocked(ctx) if err != nil { return fmt.Errorf("store_sqlite: PurgeRepo writer preflight connection: %w", err) } var hasGraphRows int - err = conn.QueryRowContext(context.Background(), `SELECT EXISTS(SELECT 1 FROM nodes WHERE repo_prefix = ? LIMIT 1)`, prefix).Scan(&hasGraphRows) + err = conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM nodes WHERE repo_prefix = ? LIMIT 1)`, prefix).Scan(&hasGraphRows) release() if err != nil { return fmt.Errorf("store_sqlite: PurgeRepo graph preflight: %w", err) } - if hasGraphRows != 0 && !s.invalidateAnalysisBeforeMutationLocked() { - return fmt.Errorf("store_sqlite: PurgeRepo could not invalidate active analysis") + if hasGraphRows != 0 { + if err := s.invalidateAnalysisGenerationLockedContext(ctx); err != nil { + return fmt.Errorf("store_sqlite: PurgeRepo invalidate active analysis: %w", err) + } } - tx, err := s.beginWrite() + owner, err := s.beginOwnedWrite(ctx) if err != nil { return err } - defer tx.Rollback() //nolint:errcheck // rollback after Commit is a no-op + defer owner.finish(&err) + tx := owner.tx // Collect this repo's node IDs first for edge deletion. Vectors are removed // below by repo_prefix so synthetic chunk rows are covered too. Edge deletion // semantics mirror scope eviction's (store.go): delete every edge touching // one of these nodes, then the nodes themselves. - ids, err := repoNodeIDsTx(tx, prefix) + ids, err := repoNodeIDsTxContext(ctx, tx, prefix) if err != nil { return err } - if err := deleteByIDColumnsTx(tx, "edges", []string{"from_id", "to_id"}, ids); err != nil { + if err := deleteByIDColumnsTxContext(ctx, tx, "edges", []string{"from_id", "to_id"}, ids); err != nil { return fmt.Errorf("store_sqlite: PurgeRepo edges: %w", err) } changed := len(ids) > 0 for _, table := range purgeSidecarTables { - res, err := tx.Exec(`DELETE FROM `+table+` WHERE repo_prefix = ?`, prefix) + res, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE repo_prefix = ?`, prefix) noteTableRows(table, res, err) if err != nil { return fmt.Errorf("store_sqlite: PurgeRepo %s: %w", table, err) @@ -130,7 +141,7 @@ func (s *Store) PurgeRepo(prefix string) error { } } - res, err := countNodeWrite(tx.Exec(`DELETE FROM nodes WHERE repo_prefix = ?`, prefix)) + res, err := countNodeWrite(tx.ExecContext(ctx, `DELETE FROM nodes WHERE repo_prefix = ?`, prefix)) if err != nil { return fmt.Errorf("store_sqlite: PurgeRepo nodes: %w", err) } @@ -138,7 +149,7 @@ func (s *Store) PurgeRepo(prefix string) error { changed = true } - if err := tx.Commit(); err != nil { + if err := owner.commit(ctx); err != nil { return err } s.firstIndexDrainsMu.Lock() @@ -373,8 +384,8 @@ func (s *Store) RekeyRepoPrefix(oldPrefix, newPrefix string) error { // caller holds writeMu. Rows are fully drained + closed before the caller // issues writes on the same tx — SQLite forbids an open read cursor while // writing on the same connection. -func repoNodeIDsTx(tx *sql.Tx, repoPrefix string) ([]string, error) { - rows, err := tx.Query(`SELECT id FROM nodes WHERE repo_prefix = ?`, repoPrefix) +func repoNodeIDsTxContext(ctx context.Context, tx *sql.Tx, repoPrefix string) ([]string, error) { + rows, err := tx.QueryContext(ctx, `SELECT id FROM nodes WHERE repo_prefix = ?`, repoPrefix) if err != nil { return nil, err } @@ -399,7 +410,7 @@ func repoNodeIDsTx(tx *sql.Tx, repoPrefix string) ([]string, error) { // of ids, chunked so each statement stays under SQLite's 999 bound-variable // limit. Mirrors scope eviction's separate from_id/to_id edge deletes // (store.go) — the semantics source for edge eviction. Empty ids is a no-op. -func deleteByIDColumnsTx(tx *sql.Tx, table string, cols, ids []string) error { +func deleteByIDColumnsTxContext(ctx context.Context, tx *sql.Tx, table string, cols, ids []string) error { if len(ids) == 0 { return nil } @@ -413,7 +424,7 @@ func deleteByIDColumnsTx(tx *sql.Tx, table string, cols, ids []string) error { for i, id := range batch { args[i] = id } - result, err := tx.Exec(`DELETE FROM `+table+` WHERE `+col+` IN (`+placeholders+`)`, args...) + result, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE `+col+` IN (`+placeholders+`)`, args...) noteTableRows(table, result, err) if err != nil { return err diff --git a/internal/graph/store_sqlite/testdata/upgrade/schema-v21.sql b/internal/graph/store_sqlite/testdata/upgrade/schema-v21.sql new file mode 100644 index 000000000..85d889bb1 --- /dev/null +++ b/internal/graph/store_sqlite/testdata/upgrade/schema-v21.sql @@ -0,0 +1,854 @@ +-- Schema of a gortex graph store, captured by TestHelperCaptureUpgradeSchema +-- from a store written by the v0.64.10 release binary indexing this repository at v0.64.10. +-- user_version: 21 + +CREATE TABLE nodes ( + id TEXT NOT NULL, + view_gen INTEGER NOT NULL DEFAULT 0, + kind TEXT NOT NULL, + name TEXT NOT NULL, + qual_name TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + start_line INTEGER NOT NULL DEFAULT 0, + end_line INTEGER NOT NULL DEFAULT 0, + start_column INTEGER NOT NULL DEFAULT 0, + end_column INTEGER NOT NULL DEFAULT 0, + language TEXT NOT NULL DEFAULT '', + repo_prefix TEXT NOT NULL DEFAULT '', + workspace_id TEXT NOT NULL DEFAULT '', + project_id TEXT NOT NULL DEFAULT '', + signature TEXT, + visibility TEXT, + doc TEXT, + external INTEGER, + return_type TEXT, + is_async INTEGER, + is_static INTEGER, + is_abstract INTEGER, + is_exported INTEGER, + updated_at INTEGER, + data_class TEXT, + clone_sig TEXT, + meta BLOB, semantic_type TEXT, semantic_source TEXT, entry_point INTEGER, entry_point_kind TEXT, search_signature TEXT, search_qual_name TEXT, search_doc TEXT, search_metadata_suppressed INTEGER, section_text TEXT, is_stub INTEGER GENERATED ALWAYS AS ( + CASE WHEN + id LIKE 'stdlib::%' OR id LIKE 'builtin::%' OR id LIKE 'external_call::%' OR id LIKE 'module::%' + OR ( + instr(id, '::') > 0 AND ( + substr(id, instr(id, '::') + 2) LIKE 'stdlib::%' + OR substr(id, instr(id, '::') + 2) LIKE 'builtin::%' + OR substr(id, instr(id, '::') + 2) LIKE 'external_call::%' + OR substr(id, instr(id, '::') + 2) LIKE 'module::%' + ) + ) + THEN 1 ELSE 0 END +) VIRTUAL, file_dir TEXT GENERATED ALWAYS AS ( + CASE WHEN file_path = '' THEN NULL + WHEN instr(replace(file_path, '\', '/'), '/') = 0 THEN '.' + WHEN rtrim(rtrim(replace(file_path, '\', '/'), replace(replace(file_path, '\', '/'), '/', '')), '/') = '' THEN '/' + ELSE rtrim(rtrim(replace(file_path, '\', '/'), replace(replace(file_path, '\', '/'), '/', '')), '/') END +) VIRTUAL, + PRIMARY KEY (id, view_gen) +) WITHOUT ROWID +; + +CREATE TABLE edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + kind TEXT NOT NULL, + file_path TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0, + confidence REAL NOT NULL DEFAULT 1.0, + confidence_label TEXT NOT NULL DEFAULT '', + origin TEXT NOT NULL DEFAULT '', + tier TEXT NOT NULL DEFAULT '', + cross_repo INTEGER NOT NULL DEFAULT 0, + view_gen INTEGER NOT NULL DEFAULT 0, + meta BLOB, is_unresolved INTEGER GENERATED ALWAYS AS ( + CASE WHEN (to_id >= 'unresolved::' AND to_id < 'unresolved:;') OR to_id LIKE '%::unresolved::%' THEN 1 ELSE 0 END +) VIRTUAL, member_receiver TEXT GENERATED ALWAYS AS ( + CASE WHEN kind = 'member_of' AND instr(to_id, '::') > 1 + AND substr(to_id, length(rtrim(to_id, replace(to_id, ':', ''))) + 1) <> '' + THEN substr(to_id, length(rtrim(to_id, replace(to_id, ':', ''))) + 1) ELSE NULL END +) VIRTUAL, member_receiver_dir TEXT GENERATED ALWAYS AS ( + CASE WHEN kind <> 'member_of' OR instr(to_id, '::') <= 1 + OR substr(to_id, length(rtrim(to_id, replace(to_id, ':', ''))) + 1) = '' THEN NULL + WHEN instr(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), '/') = 0 THEN '.' + WHEN rtrim(rtrim(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), + replace(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), '/', '')), '/') = '' THEN '/' + ELSE rtrim(rtrim(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), + replace(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), '/', '')), '/') END +) VIRTUAL, from_repo TEXT GENERATED ALWAYS AS ( + CASE WHEN instr(from_id, '/') > 1 THEN substr(from_id, 1, instr(from_id, '/') - 1) ELSE '' END +) VIRTUAL, to_repo_unresolved TEXT GENERATED ALWAYS AS ( + CASE WHEN to_id >= 'unresolved::' AND to_id < 'unresolved:;' THEN '' + WHEN instr(to_id, '::unresolved::') > 1 THEN substr(to_id, 1, instr(to_id, '::unresolved::') - 1) + ELSE NULL END +) VIRTUAL, resolve_terminal INTEGER, resolve_terminal_reason TEXT, semantic_source TEXT, + UNIQUE(from_id, to_id, kind, file_path, line, view_gen) +) +; + +CREATE VIRTUAL TABLE symbol_fts USING fts5(node_id UNINDEXED, repo_prefix UNINDEXED, tokens) +; + +CREATE VIRTUAL TABLE content_fts USING fts5(node_id UNINDEXED, repo_prefix UNINDEXED, file_path UNINDEXED, ordinal UNINDEXED, body) +; + +CREATE TABLE file_index_failures ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + file_path TEXT NOT NULL, + error TEXT NOT NULL, + permission_denied INTEGER NOT NULL DEFAULT 0, + workspace_id TEXT NOT NULL DEFAULT '', + project_id TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE file_mtimes ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + file_path TEXT NOT NULL, + mtime_ns INTEGER NOT NULL, + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE repo_index_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + indexed_sha TEXT NOT NULL DEFAULT '', + dirty INTEGER NOT NULL DEFAULT 0, + indexed_at INTEGER NOT NULL DEFAULT 0, + workspace_fp TEXT NOT NULL DEFAULT '', + node_count INTEGER NOT NULL DEFAULT 0, + edge_count INTEGER NOT NULL DEFAULT 0, + extractor_versions TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE enrichment_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + provider TEXT NOT NULL, + indexed_sha TEXT NOT NULL DEFAULT '', + completed_at INTEGER NOT NULL DEFAULT 0, + coverage REAL NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, repo_prefix, provider) +) WITHOUT ROWID +; + +CREATE TABLE contract_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + indexed_sha TEXT NOT NULL DEFAULT '', + completed_at INTEGER NOT NULL DEFAULT 0, + contract_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE clone_shingles ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + shingles BLOB, + signature TEXT, + token_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE clone_corpus_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE constant_values ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + value TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE semantic_binding_types ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + line INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + type_name TEXT NOT NULL, + PRIMARY KEY (view_gen, repo_prefix, file_path, line, name) +) WITHOUT ROWID +; + +CREATE TABLE files ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + content_hash TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + node_count INTEGER NOT NULL DEFAULT 0, + errors TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE ref_facts ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL DEFAULT '', + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + kind TEXT NOT NULL, + ref_name TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0, + origin TEXT NOT NULL DEFAULT '', + tier TEXT NOT NULL DEFAULT '', + candidates TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + lang TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix, from_id, to_id, kind, line) +) WITHOUT ROWID +; + +CREATE TABLE vectors ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + parent_id TEXT NOT NULL DEFAULT '', + dims INTEGER NOT NULL, + vec BLOB NOT NULL, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE churn_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + commit_count INTEGER NOT NULL DEFAULT 0, + age_days INTEGER NOT NULL DEFAULT 0, + churn_rate REAL NOT NULL DEFAULT 0, + last_author TEXT NOT NULL DEFAULT '', + last_commit_at TEXT NOT NULL DEFAULT '', + head_sha TEXT NOT NULL DEFAULT '', + branch TEXT NOT NULL DEFAULT '', + computed_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE coverage_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + coverage_pct REAL NOT NULL DEFAULT 0, + num_stmt INTEGER NOT NULL DEFAULT 0, + hit INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE release_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + added_in TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE blame_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + commit_sha TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + ts INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE symbol_fts_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + normalization TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE symbol_fts_rowid ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + fts_rowid INTEGER NOT NULL, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE content_fts_rowid ( + view_gen INTEGER NOT NULL DEFAULT 0, + fts_rowid INTEGER PRIMARY KEY, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE generation_file_masks ( + view_gen INTEGER NOT NULL, + repo_prefix TEXT NOT NULL, + file_path TEXT NOT NULL, + ownership_mode TEXT NOT NULL, + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE generation_node_tombstones ( + view_gen INTEGER NOT NULL, + node_id TEXT NOT NULL, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE generation_edge_sources ( + view_gen INTEGER NOT NULL, + source_id TEXT NOT NULL, + ownership_mode TEXT NOT NULL, + PRIMARY KEY (view_gen, source_id) +) WITHOUT ROWID +; + +CREATE TABLE generation_producer_completeness ( + view_gen INTEGER NOT NULL, + producer TEXT NOT NULL, + state TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, producer) +) WITHOUT ROWID +; + +CREATE TABLE analysis_generations ( + generation_id INTEGER PRIMARY KEY AUTOINCREMENT, + format_version INTEGER NOT NULL, + build_revision INTEGER NOT NULL, + created_at_unix INTEGER NOT NULL, + state INTEGER NOT NULL CHECK (state IN (0, 1, 2)), + node_count INTEGER NOT NULL CHECK (node_count >= 0), + community_count INTEGER NOT NULL CHECK (community_count >= 0), + process_count INTEGER NOT NULL CHECK (process_count >= 0), + concept_count INTEGER NOT NULL CHECK (concept_count >= 0), + pagerank_max REAL NOT NULL DEFAULT 0, + authority_max REAL NOT NULL DEFAULT 0, + hub_max REAL NOT NULL DEFAULT 0, + modularity REAL NOT NULL DEFAULT 0, + processes_truncated INTEGER NOT NULL DEFAULT 0 CHECK (processes_truncated IN (0, 1)), + processes_truncation_reason TEXT NOT NULL DEFAULT '' +) +; + +CREATE TABLE analysis_active_generation ( + slot INTEGER PRIMARY KEY CHECK (slot = 1), + generation_id INTEGER NOT NULL UNIQUE + REFERENCES analysis_generations(generation_id) ON DELETE RESTRICT +) +; + +CREATE TABLE analysis_generation_components ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + component TEXT NOT NULL, + row_count INTEGER NOT NULL CHECK (row_count >= 0), + sealed_at_unix INTEGER NOT NULL, + PRIMARY KEY (generation_id, component) +) WITHOUT ROWID +; + +CREATE TABLE analysis_communities ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + community_id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + hub TEXT NOT NULL DEFAULT '', + parent_id TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL CHECK (size >= 0), + cohesion REAL NOT NULL DEFAULT 0, + PRIMARY KEY (generation_id, community_id) +) WITHOUT ROWID +; + +CREATE TABLE analysis_community_files ( + generation_id INTEGER NOT NULL, + community_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + file_path TEXT NOT NULL, + PRIMARY KEY (generation_id, community_id, ordinal), + FOREIGN KEY (generation_id, community_id) + REFERENCES analysis_communities(generation_id, community_id) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_nodes ( + id INTEGER PRIMARY KEY, + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + node_id TEXT NOT NULL, + community_id TEXT, + pagerank REAL NOT NULL DEFAULT 0, + authority REAL NOT NULL DEFAULT 0, + hub REAL NOT NULL DEFAULT 0, + UNIQUE (generation_id, node_id), + FOREIGN KEY (generation_id, community_id) + REFERENCES analysis_communities(generation_id, community_id) +) +; + +CREATE TABLE analysis_processes ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + process_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + entry_point TEXT NOT NULL DEFAULT '', + step_count INTEGER NOT NULL CHECK (step_count >= 0), + score REAL NOT NULL DEFAULT 0, + truncated INTEGER NOT NULL DEFAULT 0 CHECK (truncated IN (0, 1)), + PRIMARY KEY (generation_id, process_id) +) WITHOUT ROWID +; + +CREATE TABLE analysis_process_files ( + generation_id INTEGER NOT NULL, + process_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + file_path TEXT NOT NULL, + PRIMARY KEY (generation_id, process_id, ordinal), + FOREIGN KEY (generation_id, process_id) + REFERENCES analysis_processes(generation_id, process_id) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_process_steps ( + generation_id INTEGER NOT NULL, + process_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + node_rowid INTEGER NOT NULL + REFERENCES analysis_nodes(id), + depth INTEGER NOT NULL CHECK (depth >= 0), + PRIMARY KEY (generation_id, process_id, ordinal), + FOREIGN KEY (generation_id, process_id) + REFERENCES analysis_processes(generation_id, process_id) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_concepts ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + token TEXT NOT NULL, + in_vocabulary INTEGER NOT NULL CHECK (in_vocabulary IN (0, 1)), + PRIMARY KEY (generation_id, token) +) WITHOUT ROWID +; + +CREATE TABLE analysis_concept_relations ( + generation_id INTEGER NOT NULL, + token TEXT NOT NULL, + related_token TEXT NOT NULL, + rank INTEGER NOT NULL CHECK (rank >= 0), + PRIMARY KEY (generation_id, token, rank, related_token), + FOREIGN KEY (generation_id, token) + REFERENCES analysis_concepts(generation_id, token) ON DELETE CASCADE, + FOREIGN KEY (generation_id, related_token) + REFERENCES analysis_concepts(generation_id, token) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_blobs ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + component TEXT NOT NULL, + payload BLOB NOT NULL, + PRIMARY KEY (generation_id, component) +) WITHOUT ROWID +; + +CREATE TABLE repository_families ( + family_id TEXT PRIMARY KEY, + common_dir_identity TEXT NOT NULL UNIQUE, + display_remote TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL, + primary_epoch INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + last_seen INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID +; + +CREATE TABLE checkouts ( + checkout_id TEXT PRIMARY KEY, + incarnation TEXT NOT NULL, + family_id TEXT NOT NULL + REFERENCES repository_families(family_id) ON DELETE RESTRICT, + root_path TEXT NOT NULL, + git_dir TEXT NOT NULL, + admin_name TEXT NOT NULL, + state TEXT NOT NULL, + desired_mode TEXT NOT NULL, + effective_mode TEXT NOT NULL, + locked INTEGER NOT NULL DEFAULT 0, + prunable INTEGER NOT NULL DEFAULT 0, + head_ref TEXT NOT NULL DEFAULT '', + head_commit TEXT NOT NULL DEFAULT '', + head_tree TEXT NOT NULL DEFAULT '', + last_accessible INTEGER NOT NULL DEFAULT 0, + unavailable_since INTEGER NOT NULL DEFAULT 0, + availability_deadline INTEGER NOT NULL DEFAULT 0, + removal_detected_at INTEGER NOT NULL DEFAULT 0, + removal_deadline INTEGER NOT NULL DEFAULT 0, + removal_evidence TEXT NOT NULL DEFAULT '', + active_intent_transition_id TEXT, + last_seen INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + UNIQUE (family_id, admin_name, incarnation) +) WITHOUT ROWID +; + +CREATE TABLE tracking_intents ( + intent_id TEXT PRIMARY KEY, + checkout_id TEXT NOT NULL REFERENCES checkouts(checkout_id) ON DELETE CASCADE, + source_kind TEXT NOT NULL, + source_locator TEXT NOT NULL, + active INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + revoked_at INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + UNIQUE (checkout_id, source_kind, source_locator) +) WITHOUT ROWID +; + +CREATE TABLE intent_transitions ( + transition_id TEXT PRIMARY KEY, + checkout_id TEXT NOT NULL UNIQUE + REFERENCES checkouts(checkout_id) ON DELETE CASCADE, + cause TEXT NOT NULL, + prior_desired_mode TEXT, + prior_effective_mode TEXT, + requested_mode TEXT, + prior_checkout_state TEXT, + source_snapshot_hash TEXT, + state TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + last_progress INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE checkout_path_evidence ( + checkout_id TEXT PRIMARY KEY + REFERENCES checkouts(checkout_id) ON DELETE CASCADE, + root_path_identity TEXT NOT NULL DEFAULT '', + root_volume_kind TEXT NOT NULL DEFAULT '', + root_volume_token TEXT NOT NULL DEFAULT '', + nearest_existing_ancestor_path TEXT NOT NULL DEFAULT '', + ancestor_volume_kind TEXT NOT NULL DEFAULT '', + ancestor_volume_token TEXT NOT NULL DEFAULT '', + common_dir_volume_kind TEXT NOT NULL DEFAULT '', + common_dir_volume_token TEXT NOT NULL DEFAULT '', + sampled_at INTEGER NOT NULL DEFAULT 0, + sample_generation INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID +; + +CREATE TABLE dedicated_graphs ( + graph_id TEXT PRIMARY KEY, + owner_checkout_id TEXT UNIQUE + REFERENCES checkouts(checkout_id) ON DELETE RESTRICT, + repo_prefix TEXT NOT NULL UNIQUE, + family_id TEXT NOT NULL + REFERENCES repository_families(family_id) ON DELETE RESTRICT, + is_primary_base INTEGER NOT NULL DEFAULT 0, + active_generation_id INTEGER, + state TEXT NOT NULL +) WITHOUT ROWID +; + +CREATE TABLE view_generations ( + generation_id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_kind TEXT NOT NULL, + graph_id TEXT NOT NULL DEFAULT '', + layer_id TEXT, + checkout_id TEXT, + generation_kind TEXT NOT NULL, + base_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + lower_view_fingerprint TEXT NOT NULL DEFAULT '', + tree_oid TEXT NOT NULL DEFAULT '', + provenance_commit_oid TEXT, + config_hash TEXT NOT NULL DEFAULT '', + extractor_versions TEXT NOT NULL DEFAULT '', + resolver_version TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL, + covered_files INTEGER NOT NULL DEFAULT 0, + affected_files INTEGER NOT NULL DEFAULT 0, + storage_bytes INTEGER NOT NULL DEFAULT 0, + completeness TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT 0, + published_at INTEGER NOT NULL DEFAULT 0, + last_selected INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '' +) +; + +CREATE TABLE view_layers ( + layer_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + graph_id TEXT NOT NULL, + checkout_id TEXT, + target_ref TEXT, + target_commit TEXT NOT NULL DEFAULT '', + target_tree TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE checkout_routes ( + checkout_id TEXT PRIMARY KEY + REFERENCES checkouts(checkout_id) ON DELETE RESTRICT, + graph_id TEXT NOT NULL, + commit_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + dirty_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + route_epoch INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL +) WITHOUT ROWID +; + +CREATE TABLE ref_views ( + ref_view_id TEXT PRIMARY KEY, + graph_id TEXT NOT NULL, + selector_kind TEXT NOT NULL, + selector_value TEXT NOT NULL, + desired_ref TEXT NOT NULL DEFAULT '', + desired_commit TEXT NOT NULL DEFAULT '', + desired_tree TEXT NOT NULL DEFAULT '', + active_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + active_ref TEXT, + active_commit TEXT, + active_tree TEXT, + enrichment_profile TEXT NOT NULL, + desired_build_fingerprint TEXT NOT NULL DEFAULT '', + active_build_fingerprint TEXT, + route_epoch INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL, + exact_view INTEGER NOT NULL DEFAULT 1, + last_resolved INTEGER NOT NULL DEFAULT 0, + last_selected INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + UNIQUE (graph_id, selector_kind, selector_value, enrichment_profile) +) WITHOUT ROWID +; + +CREATE TABLE ref_view_builds ( + build_id TEXT PRIMARY KEY, + ref_view_id TEXT NOT NULL REFERENCES ref_views(ref_view_id) ON DELETE CASCADE, + desired_ref TEXT NOT NULL DEFAULT '', + desired_commit TEXT NOT NULL DEFAULT '', + desired_tree TEXT NOT NULL DEFAULT '', + base_generation_id INTEGER, + enrichment_profile TEXT NOT NULL DEFAULT '', + build_fingerprint TEXT NOT NULL, + generation_id INTEGER, + captured_route_epoch INTEGER NOT NULL, + state TEXT NOT NULL, + build_token TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL DEFAULT 0, + last_progress INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE cleanup_journal ( + cleanup_id TEXT PRIMARY KEY, + opaque_target_ids TEXT NOT NULL, + reason TEXT NOT NULL, + phase TEXT NOT NULL, + grace_deadline INTEGER NOT NULL DEFAULT 0, + primary_epoch INTEGER NOT NULL DEFAULT 0, + last_progress INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE INDEX analysis_generations_by_state + ON analysis_generations(state, generation_id DESC) +; + +CREATE INDEX analysis_nodes_by_pagerank + ON analysis_nodes(generation_id, pagerank DESC, id ASC) +; + +CREATE INDEX analysis_nodes_by_authority + ON analysis_nodes(generation_id, authority DESC, id ASC) +; + +CREATE INDEX analysis_nodes_by_hub + ON analysis_nodes(generation_id, hub DESC, id ASC) +; + +CREATE INDEX analysis_nodes_by_community + ON analysis_nodes(generation_id, community_id, node_id) +; + +CREATE INDEX analysis_process_steps_by_node + ON analysis_process_steps(generation_id, node_rowid, process_id) +; + +CREATE INDEX analysis_concept_relations_by_related + ON analysis_concept_relations(generation_id, related_token, rank, token) +; + +CREATE UNIQUE INDEX dedicated_graphs_primary_base + ON dedicated_graphs(family_id) WHERE is_primary_base = 1 +; + +CREATE INDEX view_generations_by_graph_state + ON view_generations(graph_id, state, generation_id DESC) +; + +CREATE INDEX view_generations_by_base + ON view_generations(base_generation_id) WHERE base_generation_id IS NOT NULL +; + +CREATE INDEX checkout_routes_by_commit_generation + ON checkout_routes(commit_generation_id) WHERE commit_generation_id IS NOT NULL +; + +CREATE INDEX checkout_routes_by_dirty_generation + ON checkout_routes(dirty_generation_id) WHERE dirty_generation_id IS NOT NULL +; + +CREATE INDEX ref_views_by_active_generation + ON ref_views(active_generation_id) WHERE active_generation_id IS NOT NULL +; + +CREATE UNIQUE INDEX ref_view_builds_single_inflight + ON ref_view_builds(ref_view_id, desired_tree, base_generation_id, build_fingerprint) + WHERE state = 'building' +; + +CREATE INDEX nodes_by_repo_kind ON nodes(repo_prefix, kind) +; + +CREATE INDEX nodes_by_generation ON nodes(view_gen, id) WHERE view_gen > 0 +; + +CREATE INDEX edges_by_generation ON edges(view_gen, id) WHERE view_gen > 0 +; + +CREATE INDEX nodes_repo_files ON nodes(repo_prefix, workspace_id, language, file_path, id) WHERE kind = 'file' +; + +CREATE INDEX edges_by_unresolved ON edges(is_unresolved) WHERE is_unresolved = 1 +; + +CREATE INDEX edges_fnvalue_prefixed ON edges(to_id) WHERE to_id LIKE '%::unresolved::fnvalue::%' +; + +CREATE INDEX nodes_go_receiver_type ON nodes(repo_prefix, file_dir, name, id) WHERE language = 'go' AND kind IN ('type', 'interface') AND name <> '' AND file_path <> '' +; + +CREATE INDEX nodes_by_qual ON nodes(qual_name) WHERE qual_name <> '' +; + +CREATE INDEX edges_external ON edges(kind) WHERE (to_id GLOB 'dep::*' OR to_id GLOB 'external::*' OR to_id GLOB 'stdlib::*' OR to_id GLOB '*::stdlib::*' OR to_id GLOB 'external-call::*') +; + +CREATE INDEX nodes_missing_workspace_slugs ON nodes(repo_prefix) + WHERE workspace_id = '' OR project_id = '' +; + +CREATE INDEX clone_shingles_by_repo + ON clone_shingles(view_gen, repo_prefix, node_id) +; + +CREATE INDEX constant_values_by_file ON constant_values(view_gen, repo_prefix, file_path) +; + +CREATE INDEX files_with_errors ON files(view_gen, repo_prefix) WHERE errors <> '' +; + +CREATE INDEX ref_facts_by_file ON ref_facts(view_gen, repo_prefix, file_path) +; + +CREATE INDEX ref_facts_by_target ON ref_facts(view_gen, repo_prefix, to_id) +; + +CREATE INDEX vectors_by_repo ON vectors(view_gen, repo_prefix, node_id) +; + +CREATE INDEX churn_by_repo ON churn_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE INDEX coverage_by_repo ON coverage_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE INDEX release_by_repo ON release_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE INDEX blame_by_repo ON blame_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE UNIQUE INDEX symbol_fts_rowid_by_rowid + ON symbol_fts_rowid(fts_rowid) +; + +CREATE INDEX symbol_fts_rowid_by_repo + ON symbol_fts_rowid(view_gen, repo_prefix, fts_rowid) +; + +CREATE INDEX content_fts_rowid_by_repo_file + ON content_fts_rowid(view_gen, repo_prefix, file_path, fts_rowid) +; + +CREATE INDEX content_fts_rowid_by_file + ON content_fts_rowid(view_gen, file_path, fts_rowid) +; + +CREATE INDEX nodes_by_name ON nodes(name) +; + +CREATE INDEX nodes_by_kind ON nodes(kind) +; + +CREATE INDEX nodes_by_file ON nodes(file_path) +; + +CREATE INDEX nodes_by_repo ON nodes(repo_prefix) WHERE repo_prefix <> '' +; + +CREATE INDEX nodes_by_repo_language_name ON nodes(repo_prefix, language, name) WHERE name <> '' +; + +CREATE INDEX edges_by_from ON edges(from_id, kind) +; + +CREATE INDEX edges_by_from_line ON edges(from_id, line) +; + +CREATE INDEX edges_by_from_line_kind ON edges(from_id, line, kind) +; + +CREATE INDEX edges_by_to ON edges(to_id, kind) +; + +CREATE INDEX edges_by_kind ON edges(kind) +; + +CREATE INDEX edges_by_file ON edges(file_path, kind) +; diff --git a/internal/graph/store_sqlite/testdata/upgrade/schema-v40.sql b/internal/graph/store_sqlite/testdata/upgrade/schema-v40.sql new file mode 100644 index 000000000..5a2184e4f --- /dev/null +++ b/internal/graph/store_sqlite/testdata/upgrade/schema-v40.sql @@ -0,0 +1,1089 @@ +-- Schema of a gortex graph store, captured by TestHelperCaptureUpgradeSchema +-- from a store written by a v0.64.11 release build indexing this repository at v0.64.10. +-- user_version: 40 + +CREATE TABLE nodes ( + id TEXT NOT NULL, + view_gen INTEGER NOT NULL DEFAULT 0, + kind TEXT NOT NULL, + name TEXT NOT NULL, + qual_name TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + start_line INTEGER NOT NULL DEFAULT 0, + end_line INTEGER NOT NULL DEFAULT 0, + start_column INTEGER NOT NULL DEFAULT 0, + end_column INTEGER NOT NULL DEFAULT 0, + language TEXT NOT NULL DEFAULT '', + repo_prefix TEXT NOT NULL DEFAULT '', + workspace_id TEXT NOT NULL DEFAULT '', + project_id TEXT NOT NULL DEFAULT '', + signature TEXT, + visibility TEXT, + doc TEXT, + external INTEGER, + return_type TEXT, + is_async INTEGER, + is_static INTEGER, + is_abstract INTEGER, + is_exported INTEGER, + updated_at INTEGER, + data_class TEXT, + clone_sig TEXT, + meta BLOB, semantic_type TEXT, semantic_source TEXT, entry_point INTEGER, entry_point_kind TEXT, search_signature TEXT, search_qual_name TEXT, search_doc TEXT, search_metadata_suppressed INTEGER, section_text TEXT, is_stub INTEGER GENERATED ALWAYS AS ( + CASE WHEN + id LIKE 'stdlib::%' OR id LIKE 'builtin::%' OR id LIKE 'external_call::%' OR id LIKE 'module::%' + OR ( + instr(id, '::') > 0 AND ( + substr(id, instr(id, '::') + 2) LIKE 'stdlib::%' + OR substr(id, instr(id, '::') + 2) LIKE 'builtin::%' + OR substr(id, instr(id, '::') + 2) LIKE 'external_call::%' + OR substr(id, instr(id, '::') + 2) LIKE 'module::%' + ) + ) + THEN 1 ELSE 0 END +) VIRTUAL, file_dir TEXT GENERATED ALWAYS AS ( + CASE WHEN file_path = '' THEN NULL + WHEN instr(replace(file_path, '\', '/'), '/') = 0 THEN '.' + WHEN rtrim(rtrim(replace(file_path, '\', '/'), replace(replace(file_path, '\', '/'), '/', '')), '/') = '' THEN '/' + ELSE rtrim(rtrim(replace(file_path, '\', '/'), replace(replace(file_path, '\', '/'), '/', '')), '/') END +) VIRTUAL, + PRIMARY KEY (id, view_gen) +) WITHOUT ROWID +; + +CREATE TABLE edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + kind TEXT NOT NULL, + file_path TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0, + confidence REAL NOT NULL DEFAULT 1.0, + confidence_label TEXT NOT NULL DEFAULT '', + origin TEXT NOT NULL DEFAULT '', + tier TEXT NOT NULL DEFAULT '', + cross_repo INTEGER NOT NULL DEFAULT 0, + view_gen INTEGER NOT NULL DEFAULT 0, + meta BLOB, is_unresolved INTEGER GENERATED ALWAYS AS ( + CASE WHEN (to_id >= 'unresolved::' AND to_id < 'unresolved:;') OR to_id LIKE '%::unresolved::%' THEN 1 ELSE 0 END +) VIRTUAL, member_receiver TEXT GENERATED ALWAYS AS ( + CASE WHEN kind = 'member_of' AND instr(to_id, '::') > 1 + AND substr(to_id, length(rtrim(to_id, replace(to_id, ':', ''))) + 1) <> '' + THEN substr(to_id, length(rtrim(to_id, replace(to_id, ':', ''))) + 1) ELSE NULL END +) VIRTUAL, member_receiver_dir TEXT GENERATED ALWAYS AS ( + CASE WHEN kind <> 'member_of' OR instr(to_id, '::') <= 1 + OR substr(to_id, length(rtrim(to_id, replace(to_id, ':', ''))) + 1) = '' THEN NULL + WHEN instr(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), '/') = 0 THEN '.' + WHEN rtrim(rtrim(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), + replace(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), '/', '')), '/') = '' THEN '/' + ELSE rtrim(rtrim(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), + replace(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), '/', '')), '/') END +) VIRTUAL, from_repo TEXT GENERATED ALWAYS AS ( + CASE WHEN instr(from_id, '/') > 1 THEN substr(from_id, 1, instr(from_id, '/') - 1) ELSE '' END +) VIRTUAL, to_repo_unresolved TEXT GENERATED ALWAYS AS ( + CASE WHEN to_id >= 'unresolved::' AND to_id < 'unresolved:;' THEN '' + WHEN instr(to_id, '::unresolved::') > 1 THEN substr(to_id, 1, instr(to_id, '::unresolved::') - 1) + ELSE NULL END +) VIRTUAL, resolve_terminal INTEGER, resolve_terminal_reason TEXT, semantic_source TEXT, + UNIQUE(from_id, to_id, kind, file_path, line, view_gen) +) +; + +CREATE VIRTUAL TABLE symbol_fts USING fts5(node_id UNINDEXED, repo_prefix UNINDEXED, tokens) +; + +CREATE VIRTUAL TABLE content_fts USING fts5(node_id UNINDEXED, repo_prefix UNINDEXED, file_path UNINDEXED, ordinal UNINDEXED, body) +; + +CREATE TABLE file_index_failures ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + file_path TEXT NOT NULL, + error TEXT NOT NULL, + permission_denied INTEGER NOT NULL DEFAULT 0, + workspace_id TEXT NOT NULL DEFAULT '', + project_id TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE file_mtimes ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + file_path TEXT NOT NULL, + mtime_ns INTEGER NOT NULL, + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE repo_index_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + indexed_sha TEXT NOT NULL DEFAULT '', + dirty INTEGER NOT NULL DEFAULT 0, + indexed_at INTEGER NOT NULL DEFAULT 0, + workspace_fp TEXT NOT NULL DEFAULT '', + node_count INTEGER NOT NULL DEFAULT 0, + edge_count INTEGER NOT NULL DEFAULT 0, + extractor_versions TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE enrichment_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + provider TEXT NOT NULL, + indexed_sha TEXT NOT NULL DEFAULT '', + completed_at INTEGER NOT NULL DEFAULT 0, + coverage REAL NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, repo_prefix, provider) +) WITHOUT ROWID +; + +CREATE TABLE contract_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + indexed_sha TEXT NOT NULL DEFAULT '', + completed_at INTEGER NOT NULL DEFAULT 0, + contract_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE clone_shingles ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + shingles BLOB, + signature TEXT, + token_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE clone_corpus_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE constant_values ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + value TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE semantic_binding_types ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + line INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + type_name TEXT NOT NULL, + PRIMARY KEY (view_gen, repo_prefix, file_path, line, name) +) WITHOUT ROWID +; + +CREATE TABLE files ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + content_hash TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + node_count INTEGER NOT NULL DEFAULT 0, + errors TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE ref_facts ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL DEFAULT '', + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + kind TEXT NOT NULL, + ref_name TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0, + origin TEXT NOT NULL DEFAULT '', + tier TEXT NOT NULL DEFAULT '', + candidates TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + lang TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix, from_id, to_id, kind, line) +) WITHOUT ROWID +; + +CREATE TABLE vectors ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + parent_id TEXT NOT NULL DEFAULT '', + dims INTEGER NOT NULL, + vec BLOB NOT NULL, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE churn_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + commit_count INTEGER NOT NULL DEFAULT 0, + age_days INTEGER NOT NULL DEFAULT 0, + churn_rate REAL NOT NULL DEFAULT 0, + last_author TEXT NOT NULL DEFAULT '', + last_commit_at TEXT NOT NULL DEFAULT '', + head_sha TEXT NOT NULL DEFAULT '', + branch TEXT NOT NULL DEFAULT '', + computed_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE coverage_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + coverage_pct REAL NOT NULL DEFAULT 0, + num_stmt INTEGER NOT NULL DEFAULT 0, + hit INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE release_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + added_in TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE blame_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + commit_sha TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + ts INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE symbol_fts_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + normalization TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE symbol_fts_rowid ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + fts_rowid INTEGER NOT NULL, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE content_fts_rowid ( + view_gen INTEGER NOT NULL DEFAULT 0, + fts_rowid INTEGER PRIMARY KEY, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE generation_file_masks ( + view_gen INTEGER NOT NULL, + repo_prefix TEXT NOT NULL, + file_path TEXT NOT NULL, + ownership_mode TEXT NOT NULL, + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE generation_node_tombstones ( + view_gen INTEGER NOT NULL, + node_id TEXT NOT NULL, + claim_kind TEXT NOT NULL DEFAULT 'legacy_tombstone', + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE generation_edge_sources ( + view_gen INTEGER NOT NULL, + source_id TEXT NOT NULL, + ownership_mode TEXT NOT NULL, + PRIMARY KEY (view_gen, source_id) +) WITHOUT ROWID +; + +CREATE TABLE generation_producer_completeness ( + view_gen INTEGER NOT NULL, + producer TEXT NOT NULL, + state TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, producer) +) WITHOUT ROWID +; + +CREATE TABLE generation_contract_work ( + view_gen INTEGER NOT NULL, + token TEXT NOT NULL, + origin_generation INTEGER NOT NULL, + checkout_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL, + file_path TEXT NOT NULL, + input_version TEXT NOT NULL, + input_fingerprint TEXT NOT NULL, + state TEXT NOT NULL, + scope TEXT NOT NULL, + PRIMARY KEY (view_gen, token) +) WITHOUT ROWID +; + +CREATE TABLE generation_contract_input_state ( + view_gen INTEGER NOT NULL, + repo_prefix TEXT NOT NULL, + checkout_id TEXT NOT NULL, + input_version TEXT NOT NULL, + input_fingerprint TEXT NOT NULL, + previous_input_version TEXT NOT NULL, + previous_input_fingerprint TEXT NOT NULL, + accepted INTEGER NOT NULL CHECK(accepted IN (0,1)), + PRIMARY KEY(view_gen, repo_prefix, checkout_id) +) WITHOUT ROWID +; + +CREATE TABLE generation_contract_boundary_receipt ( + view_gen INTEGER NOT NULL,repo_prefix TEXT NOT NULL,checkout_id TEXT NOT NULL,file_path TEXT NOT NULL, + version TEXT NOT NULL,fingerprint TEXT NOT NULL,source_fingerprint TEXT NOT NULL,accepted INTEGER NOT NULL, + receipt BLOB NOT NULL, + PRIMARY KEY(view_gen,repo_prefix,checkout_id,file_path) +) WITHOUT ROWID +; + +CREATE TABLE generation_contract_boundary_keys ( + view_gen INTEGER NOT NULL,key_kind TEXT NOT NULL,lookup_key TEXT NOT NULL, + repo_prefix TEXT NOT NULL,checkout_id TEXT NOT NULL,file_path TEXT NOT NULL, + PRIMARY KEY(view_gen,key_kind,lookup_key,repo_prefix,checkout_id,file_path) +) WITHOUT ROWID +; + +CREATE TABLE generation_contract_boundary_baseline ( + view_gen INTEGER NOT NULL,repo_prefix TEXT NOT NULL,checkout_id TEXT NOT NULL, + version TEXT NOT NULL,fingerprint TEXT NOT NULL, + PRIMARY KEY(view_gen,repo_prefix,checkout_id) +) WITHOUT ROWID +; + +CREATE TABLE contract_attachments ( + repo_prefix TEXT NOT NULL, + checkout_id TEXT NOT NULL, + input_version TEXT NOT NULL, + input_fingerprint TEXT NOT NULL, + payload_generation INTEGER NOT NULL, + PRIMARY KEY(repo_prefix, checkout_id, input_version, input_fingerprint) +) WITHOUT ROWID +; + +CREATE TABLE contract_attachment_work ( + repo_prefix TEXT NOT NULL, + checkout_id TEXT NOT NULL, + work_checkout_id TEXT NOT NULL, + attachment_version TEXT NOT NULL, + attachment_fingerprint TEXT NOT NULL, + token TEXT NOT NULL, + origin_generation INTEGER NOT NULL, + file_path TEXT NOT NULL, + input_version TEXT NOT NULL, + input_fingerprint TEXT NOT NULL, + scope TEXT NOT NULL, + PRIMARY KEY(repo_prefix, checkout_id, attachment_version, attachment_fingerprint, token) +) WITHOUT ROWID +; + +CREATE TABLE contract_attachment_inputs ( + repo_prefix TEXT NOT NULL,checkout_id TEXT NOT NULL,input_version TEXT NOT NULL,input_fingerprint TEXT NOT NULL, + source_generation INTEGER NOT NULL,source_repo TEXT NOT NULL,source_checkout TEXT NOT NULL,source_version TEXT NOT NULL,source_fingerprint TEXT NOT NULL,source_accepted INTEGER NOT NULL,source_found INTEGER NOT NULL,source_effective INTEGER NOT NULL, + PRIMARY KEY(repo_prefix,checkout_id,input_version,input_fingerprint,source_generation,source_repo,source_checkout) +) WITHOUT ROWID +; + +CREATE TABLE generation_input_manifest ( + view_gen INTEGER NOT NULL, + file_path TEXT NOT NULL, + entry_state TEXT NOT NULL, + admission TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT '', + content_sha256 TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, file_path) +) WITHOUT ROWID +; + +CREATE TABLE generation_input_manifest_meta ( + view_gen INTEGER PRIMARY KEY, + manifest_version INTEGER NOT NULL, + is_full INTEGER NOT NULL, + entry_count INTEGER NOT NULL, + policy_digest TEXT NOT NULL +) +; + +CREATE TABLE generation_derivation_stamps ( + view_gen INTEGER NOT NULL, + pass TEXT NOT NULL, + derivation_version INTEGER NOT NULL, + stamped_at INTEGER NOT NULL, + PRIMARY KEY (view_gen, pass) +) WITHOUT ROWID +; + +CREATE TABLE analysis_generations ( + generation_id INTEGER PRIMARY KEY AUTOINCREMENT, + format_version INTEGER NOT NULL, + build_revision INTEGER NOT NULL, + created_at_unix INTEGER NOT NULL, + state INTEGER NOT NULL CHECK (state IN (0, 1, 2)), + node_count INTEGER NOT NULL CHECK (node_count >= 0), + community_count INTEGER NOT NULL CHECK (community_count >= 0), + process_count INTEGER NOT NULL CHECK (process_count >= 0), + concept_count INTEGER NOT NULL CHECK (concept_count >= 0), + pagerank_max REAL NOT NULL DEFAULT 0, + authority_max REAL NOT NULL DEFAULT 0, + hub_max REAL NOT NULL DEFAULT 0, + modularity REAL NOT NULL DEFAULT 0, + processes_truncated INTEGER NOT NULL DEFAULT 0 CHECK (processes_truncated IN (0, 1)), + processes_truncation_reason TEXT NOT NULL DEFAULT '', + view_gen INTEGER NOT NULL DEFAULT 0 +) +; + +CREATE TABLE analysis_active_generation ( + view_gen INTEGER NOT NULL DEFAULT 0, + slot INTEGER NOT NULL CHECK (slot = 1), + generation_id INTEGER NOT NULL UNIQUE + REFERENCES analysis_generations(generation_id) ON DELETE RESTRICT, + PRIMARY KEY (view_gen, slot) +) +; + +CREATE TABLE analysis_generation_components ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + component TEXT NOT NULL, + row_count INTEGER NOT NULL CHECK (row_count >= 0), + sealed_at_unix INTEGER NOT NULL, + PRIMARY KEY (generation_id, component) +) WITHOUT ROWID +; + +CREATE TABLE analysis_communities ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + community_id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + hub TEXT NOT NULL DEFAULT '', + parent_id TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL CHECK (size >= 0), + cohesion REAL NOT NULL DEFAULT 0, + PRIMARY KEY (generation_id, community_id) +) WITHOUT ROWID +; + +CREATE TABLE analysis_community_files ( + generation_id INTEGER NOT NULL, + community_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + file_path TEXT NOT NULL, + PRIMARY KEY (generation_id, community_id, ordinal), + FOREIGN KEY (generation_id, community_id) + REFERENCES analysis_communities(generation_id, community_id) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_nodes ( + id INTEGER PRIMARY KEY, + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + node_id TEXT NOT NULL, + community_id TEXT, + pagerank REAL NOT NULL DEFAULT 0, + authority REAL NOT NULL DEFAULT 0, + hub REAL NOT NULL DEFAULT 0, + UNIQUE (generation_id, node_id), + FOREIGN KEY (generation_id, community_id) + REFERENCES analysis_communities(generation_id, community_id) +) +; + +CREATE TABLE analysis_processes ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + process_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + entry_point TEXT NOT NULL DEFAULT '', + step_count INTEGER NOT NULL CHECK (step_count >= 0), + score REAL NOT NULL DEFAULT 0, + truncated INTEGER NOT NULL DEFAULT 0 CHECK (truncated IN (0, 1)), + PRIMARY KEY (generation_id, process_id) +) WITHOUT ROWID +; + +CREATE TABLE analysis_process_files ( + generation_id INTEGER NOT NULL, + process_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + file_path TEXT NOT NULL, + PRIMARY KEY (generation_id, process_id, ordinal), + FOREIGN KEY (generation_id, process_id) + REFERENCES analysis_processes(generation_id, process_id) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_process_steps ( + generation_id INTEGER NOT NULL, + process_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + node_rowid INTEGER NOT NULL + REFERENCES analysis_nodes(id), + depth INTEGER NOT NULL CHECK (depth >= 0), + PRIMARY KEY (generation_id, process_id, ordinal), + FOREIGN KEY (generation_id, process_id) + REFERENCES analysis_processes(generation_id, process_id) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_concepts ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + token TEXT NOT NULL, + in_vocabulary INTEGER NOT NULL CHECK (in_vocabulary IN (0, 1)), + PRIMARY KEY (generation_id, token) +) WITHOUT ROWID +; + +CREATE TABLE analysis_concept_relations ( + generation_id INTEGER NOT NULL, + token TEXT NOT NULL, + related_token TEXT NOT NULL, + rank INTEGER NOT NULL CHECK (rank >= 0), + PRIMARY KEY (generation_id, token, rank, related_token), + FOREIGN KEY (generation_id, token) + REFERENCES analysis_concepts(generation_id, token) ON DELETE CASCADE, + FOREIGN KEY (generation_id, related_token) + REFERENCES analysis_concepts(generation_id, token) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_blobs ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + component TEXT NOT NULL, + payload BLOB NOT NULL, + PRIMARY KEY (generation_id, component) +) WITHOUT ROWID +; + +CREATE TABLE repository_families ( + family_id TEXT PRIMARY KEY, + common_dir_identity TEXT NOT NULL UNIQUE, + display_remote TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL, + primary_epoch INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + last_seen INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID +; + +CREATE TABLE checkouts ( + checkout_id TEXT PRIMARY KEY, + incarnation TEXT NOT NULL, + family_id TEXT NOT NULL + REFERENCES repository_families(family_id) ON DELETE RESTRICT, + root_path TEXT NOT NULL, + git_dir TEXT NOT NULL, + admin_name TEXT NOT NULL, + state TEXT NOT NULL, + desired_mode TEXT NOT NULL, + effective_mode TEXT NOT NULL, + locked INTEGER NOT NULL DEFAULT 0, + prunable INTEGER NOT NULL DEFAULT 0, + head_ref TEXT NOT NULL DEFAULT '', + head_commit TEXT NOT NULL DEFAULT '', + head_tree TEXT NOT NULL DEFAULT '', + last_accessible INTEGER NOT NULL DEFAULT 0, + unavailable_since INTEGER NOT NULL DEFAULT 0, + availability_deadline INTEGER NOT NULL DEFAULT 0, + removal_detected_at INTEGER NOT NULL DEFAULT 0, + removal_deadline INTEGER NOT NULL DEFAULT 0, + removal_evidence TEXT NOT NULL DEFAULT '', + active_intent_transition_id TEXT, + last_seen INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + UNIQUE (family_id, admin_name, incarnation) +) WITHOUT ROWID +; + +CREATE TABLE tracking_intents ( + intent_id TEXT PRIMARY KEY, + checkout_id TEXT NOT NULL REFERENCES checkouts(checkout_id) ON DELETE CASCADE, + source_kind TEXT NOT NULL, + source_locator TEXT NOT NULL, + active INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + revoked_at INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + UNIQUE (checkout_id, source_kind, source_locator) +) WITHOUT ROWID +; + +CREATE TABLE intent_transitions ( + transition_id TEXT PRIMARY KEY, + checkout_id TEXT NOT NULL UNIQUE + REFERENCES checkouts(checkout_id) ON DELETE CASCADE, + cause TEXT NOT NULL, + prior_desired_mode TEXT, + prior_effective_mode TEXT, + requested_mode TEXT, + prior_checkout_state TEXT, + source_snapshot_hash TEXT, + state TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + last_progress INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE checkout_path_evidence ( + checkout_id TEXT PRIMARY KEY + REFERENCES checkouts(checkout_id) ON DELETE CASCADE, + root_path_identity TEXT NOT NULL DEFAULT '', + root_volume_kind TEXT NOT NULL DEFAULT '', + root_volume_token TEXT NOT NULL DEFAULT '', + nearest_existing_ancestor_path TEXT NOT NULL DEFAULT '', + ancestor_volume_kind TEXT NOT NULL DEFAULT '', + ancestor_volume_token TEXT NOT NULL DEFAULT '', + common_dir_volume_kind TEXT NOT NULL DEFAULT '', + common_dir_volume_token TEXT NOT NULL DEFAULT '', + sampled_at INTEGER NOT NULL DEFAULT 0, + sample_generation INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID +; + +CREATE TABLE dedicated_graphs ( + graph_id TEXT PRIMARY KEY, + owner_checkout_id TEXT UNIQUE + REFERENCES checkouts(checkout_id) ON DELETE RESTRICT, + repo_prefix TEXT NOT NULL UNIQUE, + family_id TEXT NOT NULL + REFERENCES repository_families(family_id) ON DELETE RESTRICT, + is_primary_base INTEGER NOT NULL DEFAULT 0, + active_generation_id INTEGER, + state TEXT NOT NULL +) WITHOUT ROWID +; + +CREATE TABLE view_generations ( + generation_id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_kind TEXT NOT NULL, + graph_id TEXT NOT NULL DEFAULT '', + layer_id TEXT, + checkout_id TEXT, + generation_kind TEXT NOT NULL, + base_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + lower_view_fingerprint TEXT NOT NULL DEFAULT '', + tree_oid TEXT NOT NULL DEFAULT '', + provenance_commit_oid TEXT, + config_hash TEXT NOT NULL DEFAULT '', + extractor_versions TEXT NOT NULL DEFAULT '', + resolver_version TEXT NOT NULL DEFAULT '', + dependency_revision TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL, + covered_files INTEGER NOT NULL DEFAULT 0, + affected_files INTEGER NOT NULL DEFAULT 0, + storage_bytes INTEGER NOT NULL DEFAULT 0, + completeness TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT 0, + published_at INTEGER NOT NULL DEFAULT 0, + last_selected INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '' +) +; + +CREATE TABLE view_layers ( + layer_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + graph_id TEXT NOT NULL, + checkout_id TEXT, + target_ref TEXT, + target_commit TEXT NOT NULL DEFAULT '', + target_tree TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE checkout_routes ( + checkout_id TEXT PRIMARY KEY + REFERENCES checkouts(checkout_id) ON DELETE RESTRICT, + graph_id TEXT NOT NULL, + commit_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + dirty_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + route_epoch INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL +) WITHOUT ROWID +; + +CREATE TABLE ref_views ( + ref_view_id TEXT PRIMARY KEY, + graph_id TEXT NOT NULL, + selector_kind TEXT NOT NULL, + selector_value TEXT NOT NULL, + desired_ref TEXT NOT NULL DEFAULT '', + desired_commit TEXT NOT NULL DEFAULT '', + desired_tree TEXT NOT NULL DEFAULT '', + active_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + active_ref TEXT, + active_commit TEXT, + active_tree TEXT, + enrichment_profile TEXT NOT NULL, + desired_build_fingerprint TEXT NOT NULL DEFAULT '', + active_build_fingerprint TEXT, + route_epoch INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL, + exact_view INTEGER NOT NULL DEFAULT 1, + last_resolved INTEGER NOT NULL DEFAULT 0, + last_selected INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + UNIQUE (graph_id, selector_kind, selector_value, enrichment_profile) +) WITHOUT ROWID +; + +CREATE TABLE ref_view_builds ( + build_id TEXT PRIMARY KEY, + ref_view_id TEXT NOT NULL REFERENCES ref_views(ref_view_id) ON DELETE CASCADE, + desired_ref TEXT NOT NULL DEFAULT '', + desired_commit TEXT NOT NULL DEFAULT '', + desired_tree TEXT NOT NULL DEFAULT '', + base_generation_id INTEGER, + enrichment_profile TEXT NOT NULL DEFAULT '', + build_fingerprint TEXT NOT NULL, + generation_id INTEGER, + captured_route_epoch INTEGER NOT NULL, + state TEXT NOT NULL, + build_token TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL DEFAULT 0, + last_progress INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE cleanup_journal ( + cleanup_id TEXT PRIMARY KEY, + opaque_target_ids TEXT NOT NULL, + reason TEXT NOT NULL, + phase TEXT NOT NULL, + grace_deadline INTEGER NOT NULL DEFAULT 0, + primary_epoch INTEGER NOT NULL DEFAULT 0, + last_progress INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE dedicated_base_publications ( + graph_id TEXT PRIMARY KEY REFERENCES dedicated_graphs(graph_id) ON DELETE CASCADE, + owner_checkout_id TEXT NOT NULL, + owner_incarnation TEXT NOT NULL, + owner_generation_floor INTEGER NOT NULL, + repo_prefix TEXT NOT NULL, + family_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + authority_token TEXT NOT NULL, + desired_epoch INTEGER NOT NULL DEFAULT 0, + tree_oid TEXT NOT NULL DEFAULT '', + config_hash TEXT NOT NULL DEFAULT '', + extractor_versions TEXT NOT NULL DEFAULT '', + resolver_version TEXT NOT NULL DEFAULT '', + dependency_revision TEXT NOT NULL DEFAULT '', + attempt_token TEXT NOT NULL DEFAULT '', + attempt_state TEXT NOT NULL DEFAULT 'idle', + generation_id INTEGER NOT NULL DEFAULT 0, + base_generation_id INTEGER NOT NULL DEFAULT 0, + layer_id TEXT NOT NULL DEFAULT '', + lower_view_fingerprint TEXT NOT NULL DEFAULT '', + expected_active_generation_id INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE generation_row_counts ( + view_gen INTEGER PRIMARY KEY, + nodes INTEGER NOT NULL DEFAULT 0, + edges INTEGER NOT NULL DEFAULT 0 +) +; + +CREATE TABLE generation_row_counts_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + seeded INTEGER NOT NULL, + installed_at INTEGER NOT NULL, + seeded_at INTEGER NOT NULL DEFAULT 0 +) +; + +CREATE TABLE symbol_fts_generation_revisions ( + view_gen INTEGER PRIMARY KEY, + rev INTEGER NOT NULL +) +; + +CREATE INDEX generation_contract_work_scope + ON generation_contract_work(view_gen, repo_prefix, checkout_id, token) +; + +CREATE INDEX contract_attachment_payload ON contract_attachments(payload_generation) +; + +CREATE INDEX contract_input_identity ON generation_contract_input_state(repo_prefix, checkout_id, input_version, input_fingerprint) +; + +CREATE INDEX contract_work_pending_scope ON generation_contract_work(view_gen,repo_prefix,checkout_id,state,token) +; + +CREATE INDEX contract_attachment_work_token ON contract_attachment_work(repo_prefix, work_checkout_id, token) +; + +CREATE INDEX contract_attachment_input_source ON contract_attachment_inputs(source_repo,source_version,source_fingerprint,source_found,source_effective) +; + +CREATE INDEX contract_boundary_keys_file ON generation_contract_boundary_keys(view_gen,repo_prefix,checkout_id,file_path,key_kind,lookup_key) +; + +CREATE INDEX contract_boundary_receipt_stamps ON generation_contract_boundary_receipt(view_gen,repo_prefix,checkout_id,file_path,fingerprint,source_fingerprint,accepted) +; + +CREATE INDEX analysis_generations_by_state + ON analysis_generations(state, generation_id DESC) +; + +CREATE INDEX analysis_nodes_by_pagerank + ON analysis_nodes(generation_id, pagerank DESC, id ASC) +; + +CREATE INDEX analysis_nodes_by_authority + ON analysis_nodes(generation_id, authority DESC, id ASC) +; + +CREATE INDEX analysis_nodes_by_hub + ON analysis_nodes(generation_id, hub DESC, id ASC) +; + +CREATE INDEX analysis_nodes_by_community + ON analysis_nodes(generation_id, community_id, node_id) +; + +CREATE INDEX analysis_process_step_node_fk + ON analysis_process_steps(node_rowid) +; + +CREATE INDEX analysis_concept_relations_by_related + ON analysis_concept_relations(generation_id, related_token, rank, token) +; + +CREATE UNIQUE INDEX dedicated_graphs_primary_base + ON dedicated_graphs(family_id) WHERE is_primary_base = 1 +; + +CREATE INDEX view_generations_by_graph_state + ON view_generations(graph_id, state, generation_id DESC) +; + +CREATE INDEX view_generations_by_base + ON view_generations(base_generation_id) WHERE base_generation_id IS NOT NULL +; + +CREATE INDEX checkout_routes_by_commit_generation + ON checkout_routes(commit_generation_id) WHERE commit_generation_id IS NOT NULL +; + +CREATE INDEX checkout_routes_by_dirty_generation + ON checkout_routes(dirty_generation_id) WHERE dirty_generation_id IS NOT NULL +; + +CREATE INDEX ref_views_by_active_generation + ON ref_views(active_generation_id) WHERE active_generation_id IS NOT NULL +; + +CREATE UNIQUE INDEX ref_view_builds_single_inflight + ON ref_view_builds(ref_view_id, desired_tree, base_generation_id, build_fingerprint) + WHERE state = 'building' +; + +CREATE INDEX nodes_by_repo_kind ON nodes(repo_prefix, kind) +; + +CREATE INDEX nodes_by_generation ON nodes(view_gen, id) +; + +CREATE INDEX nodes_name_candidates ON nodes(view_gen, repo_prefix, length(name), id, name, kind, file_path, workspace_id, project_id) WHERE name <> '' +; + +CREATE INDEX edges_by_generation ON edges(view_gen, id) +; + +CREATE INDEX edges_contract_owner_generation ON edges(view_gen, kind) WHERE kind IN ('provides','consumes','handles_route') +; + +CREATE INDEX nodes_contract_seed_candidates ON nodes(repo_prefix,kind,view_gen,id,language,meta) WHERE kind IN ('field','type','interface','method') +; + +CREATE INDEX nodes_repo_files ON nodes(repo_prefix, workspace_id, language, file_path, id) WHERE kind = 'file' +; + +CREATE INDEX edges_by_unresolved ON edges(is_unresolved) WHERE is_unresolved = 1 +; + +CREATE INDEX edges_fnvalue_prefixed ON edges(view_gen, to_id) WHERE to_id LIKE '%::unresolved::fnvalue::%' +; + +CREATE INDEX nodes_go_receiver_type ON nodes(repo_prefix, file_dir, name, id) WHERE language = 'go' AND kind IN ('type', 'interface') AND name <> '' AND file_path <> '' +; + +CREATE INDEX nodes_by_qual ON nodes(qual_name) WHERE qual_name <> '' +; + +CREATE INDEX edges_external ON edges(kind) WHERE (to_id GLOB 'dep::*' OR to_id GLOB 'external::*' OR to_id GLOB 'stdlib::*' OR to_id GLOB '*::stdlib::*' OR to_id GLOB 'external-call::*') +; + +CREATE INDEX nodes_missing_workspace_slugs ON nodes(repo_prefix) + WHERE workspace_id = '' OR project_id = '' +; + +CREATE INDEX clone_shingles_by_repo + ON clone_shingles(view_gen, repo_prefix, node_id) +; + +CREATE INDEX constant_values_by_file ON constant_values(view_gen, repo_prefix, file_path) +; + +CREATE INDEX files_with_errors ON files(view_gen, repo_prefix) WHERE errors <> '' +; + +CREATE INDEX ref_facts_by_file ON ref_facts(view_gen, repo_prefix, file_path) +; + +CREATE INDEX ref_facts_by_target ON ref_facts(view_gen, repo_prefix, to_id) +; + +CREATE INDEX vectors_by_repo ON vectors(view_gen, repo_prefix, node_id) +; + +CREATE INDEX churn_by_repo ON churn_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE INDEX coverage_by_repo ON coverage_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE INDEX release_by_repo ON release_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE INDEX blame_by_repo ON blame_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE UNIQUE INDEX symbol_fts_rowid_by_rowid + ON symbol_fts_rowid(fts_rowid) +; + +CREATE INDEX symbol_fts_rowid_by_repo + ON symbol_fts_rowid(view_gen, repo_prefix, fts_rowid) +; + +CREATE INDEX symbol_fts_rowid_by_generation + ON symbol_fts_rowid(view_gen, fts_rowid) +; + +CREATE INDEX content_fts_rowid_by_repo_file + ON content_fts_rowid(view_gen, repo_prefix, file_path, fts_rowid) +; + +CREATE INDEX content_fts_rowid_by_file + ON content_fts_rowid(view_gen, file_path, fts_rowid) +; + +CREATE INDEX nodes_stats_histogram ON nodes(view_gen, kind, language) +; + +CREATE INDEX nodes_by_name ON nodes(name, view_gen, id, repo_prefix, kind, file_path) +; + +CREATE INDEX nodes_by_kind ON nodes(kind, view_gen) +; + +CREATE INDEX nodes_by_file ON nodes(file_path, view_gen) +; + +CREATE INDEX nodes_by_repo ON nodes(repo_prefix, view_gen) +; + +CREATE INDEX nodes_by_repo_language_name ON nodes(repo_prefix, language, name, view_gen) WHERE name <> '' +; + +CREATE INDEX edges_by_from ON edges(view_gen, from_id, kind) +; + +CREATE INDEX edges_by_from_line ON edges(view_gen, from_id, line) +; + +CREATE INDEX edges_by_from_line_kind ON edges(view_gen, from_id, line, kind) +; + +CREATE INDEX edges_by_to ON edges(view_gen, to_id, kind) +; + +CREATE INDEX edges_by_kind ON edges(kind) +; + +CREATE INDEX edges_by_file ON edges(file_path, kind) +; + +CREATE INDEX edges_by_file_generation ON edges(file_path, view_gen) +; + +CREATE TRIGGER generation_row_counts_edges_delete AFTER DELETE ON edges BEGIN INSERT INTO generation_row_counts(view_gen, edges) VALUES (OLD.view_gen, -1) ON CONFLICT(view_gen) DO UPDATE SET edges = edges + (-1); END +; + +CREATE TRIGGER generation_row_counts_edges_insert AFTER INSERT ON edges BEGIN INSERT INTO generation_row_counts(view_gen, edges) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET edges = edges + (1); END +; + +CREATE TRIGGER generation_row_counts_edges_move AFTER UPDATE OF view_gen ON edges WHEN OLD.view_gen IS NOT NEW.view_gen BEGIN INSERT INTO generation_row_counts(view_gen, edges) VALUES (OLD.view_gen, -1) ON CONFLICT(view_gen) DO UPDATE SET edges = edges + (-1); INSERT INTO generation_row_counts(view_gen, edges) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET edges = edges + (1); END +; + +CREATE TRIGGER generation_row_counts_nodes_delete AFTER DELETE ON nodes BEGIN INSERT INTO generation_row_counts(view_gen, nodes) VALUES (OLD.view_gen, -1) ON CONFLICT(view_gen) DO UPDATE SET nodes = nodes + (-1); END +; + +CREATE TRIGGER generation_row_counts_nodes_insert AFTER INSERT ON nodes BEGIN INSERT INTO generation_row_counts(view_gen, nodes) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET nodes = nodes + (1); END +; + +CREATE TRIGGER generation_row_counts_nodes_move AFTER UPDATE OF view_gen ON nodes WHEN OLD.view_gen IS NOT NEW.view_gen BEGIN INSERT INTO generation_row_counts(view_gen, nodes) VALUES (OLD.view_gen, -1) ON CONFLICT(view_gen) DO UPDATE SET nodes = nodes + (-1); INSERT INTO generation_row_counts(view_gen, nodes) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET nodes = nodes + (1); END +; + +CREATE TRIGGER symbol_fts_generation_revisions_delete AFTER DELETE ON symbol_fts_rowid BEGIN INSERT INTO symbol_fts_generation_revisions(view_gen, rev) VALUES (OLD.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET rev = rev + 1; END +; + +CREATE TRIGGER symbol_fts_generation_revisions_insert AFTER INSERT ON symbol_fts_rowid BEGIN INSERT INTO symbol_fts_generation_revisions(view_gen, rev) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET rev = rev + 1; END +; + +CREATE TRIGGER symbol_fts_generation_revisions_update AFTER UPDATE ON symbol_fts_rowid BEGIN INSERT INTO symbol_fts_generation_revisions(view_gen, rev) VALUES (OLD.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET rev = rev + 1; INSERT INTO symbol_fts_generation_revisions(view_gen, rev) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET rev = rev + 1; END +; diff --git a/internal/graph/store_sqlite/testdata/upgrade/schema-v43.sql b/internal/graph/store_sqlite/testdata/upgrade/schema-v43.sql new file mode 100644 index 000000000..d050d0198 --- /dev/null +++ b/internal/graph/store_sqlite/testdata/upgrade/schema-v43.sql @@ -0,0 +1,1092 @@ +-- Schema of a gortex graph store, captured by TestHelperCaptureUpgradeSchema +-- from a store written by a schema v43 build indexing this repository at v0.64.10. +-- user_version: 43 + +CREATE TABLE nodes ( + id TEXT NOT NULL, + view_gen INTEGER NOT NULL DEFAULT 0, + kind TEXT NOT NULL, + name TEXT NOT NULL, + qual_name TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + start_line INTEGER NOT NULL DEFAULT 0, + end_line INTEGER NOT NULL DEFAULT 0, + start_column INTEGER NOT NULL DEFAULT 0, + end_column INTEGER NOT NULL DEFAULT 0, + language TEXT NOT NULL DEFAULT '', + repo_prefix TEXT NOT NULL DEFAULT '', + workspace_id TEXT NOT NULL DEFAULT '', + project_id TEXT NOT NULL DEFAULT '', + signature TEXT, + visibility TEXT, + doc TEXT, + external INTEGER, + return_type TEXT, + is_async INTEGER, + is_static INTEGER, + is_abstract INTEGER, + is_exported INTEGER, + updated_at INTEGER, + data_class TEXT, + clone_sig TEXT, + meta BLOB, semantic_type TEXT, semantic_source TEXT, entry_point INTEGER, entry_point_kind TEXT, search_signature TEXT, search_qual_name TEXT, search_doc TEXT, search_metadata_suppressed INTEGER, section_text TEXT, is_stub INTEGER GENERATED ALWAYS AS ( + CASE WHEN + id LIKE 'stdlib::%' OR id LIKE 'builtin::%' OR id LIKE 'external_call::%' OR id LIKE 'module::%' + OR ( + instr(id, '::') > 0 AND ( + substr(id, instr(id, '::') + 2) LIKE 'stdlib::%' + OR substr(id, instr(id, '::') + 2) LIKE 'builtin::%' + OR substr(id, instr(id, '::') + 2) LIKE 'external_call::%' + OR substr(id, instr(id, '::') + 2) LIKE 'module::%' + ) + ) + THEN 1 ELSE 0 END +) VIRTUAL, file_dir TEXT GENERATED ALWAYS AS ( + CASE WHEN file_path = '' THEN NULL + WHEN instr(replace(file_path, '\', '/'), '/') = 0 THEN '.' + WHEN rtrim(rtrim(replace(file_path, '\', '/'), replace(replace(file_path, '\', '/'), '/', '')), '/') = '' THEN '/' + ELSE rtrim(rtrim(replace(file_path, '\', '/'), replace(replace(file_path, '\', '/'), '/', '')), '/') END +) VIRTUAL, + PRIMARY KEY (id, view_gen) +) WITHOUT ROWID +; + +CREATE TABLE edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + kind TEXT NOT NULL, + file_path TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0, + confidence REAL NOT NULL DEFAULT 1.0, + confidence_label TEXT NOT NULL DEFAULT '', + origin TEXT NOT NULL DEFAULT '', + tier TEXT NOT NULL DEFAULT '', + cross_repo INTEGER NOT NULL DEFAULT 0, + view_gen INTEGER NOT NULL DEFAULT 0, + meta BLOB, is_unresolved INTEGER GENERATED ALWAYS AS ( + CASE WHEN (to_id >= 'unresolved::' AND to_id < 'unresolved:;') OR to_id LIKE '%::unresolved::%' THEN 1 ELSE 0 END +) VIRTUAL, member_receiver TEXT GENERATED ALWAYS AS ( + CASE WHEN kind = 'member_of' AND instr(to_id, '::') > 1 + AND substr(to_id, length(rtrim(to_id, replace(to_id, ':', ''))) + 1) <> '' + THEN substr(to_id, length(rtrim(to_id, replace(to_id, ':', ''))) + 1) ELSE NULL END +) VIRTUAL, member_receiver_dir TEXT GENERATED ALWAYS AS ( + CASE WHEN kind <> 'member_of' OR instr(to_id, '::') <= 1 + OR substr(to_id, length(rtrim(to_id, replace(to_id, ':', ''))) + 1) = '' THEN NULL + WHEN instr(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), '/') = 0 THEN '.' + WHEN rtrim(rtrim(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), + replace(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), '/', '')), '/') = '' THEN '/' + ELSE rtrim(rtrim(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), + replace(replace(substr(to_id, 1, length(rtrim(to_id, replace(to_id, ':', ''))) - 2), '\', '/'), '/', '')), '/') END +) VIRTUAL, from_repo TEXT GENERATED ALWAYS AS ( + CASE WHEN instr(from_id, '/') > 1 THEN substr(from_id, 1, instr(from_id, '/') - 1) ELSE '' END +) VIRTUAL, to_repo_unresolved TEXT GENERATED ALWAYS AS ( + CASE WHEN to_id >= 'unresolved::' AND to_id < 'unresolved:;' THEN '' + WHEN instr(to_id, '::unresolved::') > 1 THEN substr(to_id, 1, instr(to_id, '::unresolved::') - 1) + ELSE NULL END +) VIRTUAL, resolve_terminal INTEGER, resolve_terminal_reason TEXT, semantic_source TEXT, + UNIQUE(from_id, to_id, kind, file_path, line, view_gen) +) +; + +CREATE VIRTUAL TABLE symbol_fts USING fts5(node_id UNINDEXED, repo_prefix UNINDEXED, tokens) +; + +CREATE VIRTUAL TABLE content_fts USING fts5(node_id UNINDEXED, repo_prefix UNINDEXED, file_path UNINDEXED, ordinal UNINDEXED, body) +; + +CREATE TABLE file_index_failures ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + file_path TEXT NOT NULL, + error TEXT NOT NULL, + permission_denied INTEGER NOT NULL DEFAULT 0, + workspace_id TEXT NOT NULL DEFAULT '', + project_id TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE file_mtimes ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + file_path TEXT NOT NULL, + mtime_ns INTEGER NOT NULL, + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE repo_index_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + indexed_sha TEXT NOT NULL DEFAULT '', + dirty INTEGER NOT NULL DEFAULT 0, + indexed_at INTEGER NOT NULL DEFAULT 0, + workspace_fp TEXT NOT NULL DEFAULT '', + node_count INTEGER NOT NULL DEFAULT 0, + edge_count INTEGER NOT NULL DEFAULT 0, + extractor_versions TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE enrichment_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + provider TEXT NOT NULL, + indexed_sha TEXT NOT NULL DEFAULT '', + completed_at INTEGER NOT NULL DEFAULT 0, + coverage REAL NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, repo_prefix, provider) +) WITHOUT ROWID +; + +CREATE TABLE contract_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + indexed_sha TEXT NOT NULL DEFAULT '', + completed_at INTEGER NOT NULL DEFAULT 0, + contract_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE clone_shingles ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + shingles BLOB, + signature TEXT, + token_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE clone_corpus_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE constant_values ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + value TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE semantic_binding_types ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + line INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + type_name TEXT NOT NULL, + PRIMARY KEY (view_gen, repo_prefix, file_path, line, name) +) WITHOUT ROWID +; + +CREATE TABLE files ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + content_hash TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + node_count INTEGER NOT NULL DEFAULT 0, + errors TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE ref_facts ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL DEFAULT '', + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + kind TEXT NOT NULL, + ref_name TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0, + origin TEXT NOT NULL DEFAULT '', + tier TEXT NOT NULL DEFAULT '', + candidates TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + lang TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix, from_id, to_id, kind, line) +) WITHOUT ROWID +; + +CREATE TABLE vectors ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + parent_id TEXT NOT NULL DEFAULT '', + dims INTEGER NOT NULL, + vec BLOB NOT NULL, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE churn_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + commit_count INTEGER NOT NULL DEFAULT 0, + age_days INTEGER NOT NULL DEFAULT 0, + churn_rate REAL NOT NULL DEFAULT 0, + last_author TEXT NOT NULL DEFAULT '', + last_commit_at TEXT NOT NULL DEFAULT '', + head_sha TEXT NOT NULL DEFAULT '', + branch TEXT NOT NULL DEFAULT '', + computed_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE coverage_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + coverage_pct REAL NOT NULL DEFAULT 0, + num_stmt INTEGER NOT NULL DEFAULT 0, + hit INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE release_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + added_in TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE blame_enrichment ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + commit_sha TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + ts INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE symbol_fts_state ( + view_gen INTEGER NOT NULL DEFAULT 0, + repo_prefix TEXT NOT NULL, + normalization TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, repo_prefix) +) WITHOUT ROWID +; + +CREATE TABLE symbol_fts_rowid ( + view_gen INTEGER NOT NULL DEFAULT 0, + node_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL DEFAULT '', + fts_rowid INTEGER NOT NULL, + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE content_fts_rowid ( + view_gen INTEGER NOT NULL DEFAULT 0, + fts_rowid INTEGER PRIMARY KEY, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE generation_file_masks ( + view_gen INTEGER NOT NULL, + repo_prefix TEXT NOT NULL, + file_path TEXT NOT NULL, + ownership_mode TEXT NOT NULL, + PRIMARY KEY (view_gen, repo_prefix, file_path) +) WITHOUT ROWID +; + +CREATE TABLE generation_node_tombstones ( + view_gen INTEGER NOT NULL, + node_id TEXT NOT NULL, + claim_kind TEXT NOT NULL DEFAULT 'legacy_tombstone', + PRIMARY KEY (view_gen, node_id) +) WITHOUT ROWID +; + +CREATE TABLE generation_edge_sources ( + view_gen INTEGER NOT NULL, + source_id TEXT NOT NULL, + ownership_mode TEXT NOT NULL, + PRIMARY KEY (view_gen, source_id) +) WITHOUT ROWID +; + +CREATE TABLE generation_producer_completeness ( + view_gen INTEGER NOT NULL, + producer TEXT NOT NULL, + state TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, producer) +) WITHOUT ROWID +; + +CREATE TABLE generation_contract_work ( + view_gen INTEGER NOT NULL, + token TEXT NOT NULL, + origin_generation INTEGER NOT NULL, + checkout_id TEXT NOT NULL, + repo_prefix TEXT NOT NULL, + file_path TEXT NOT NULL, + input_version TEXT NOT NULL, + input_fingerprint TEXT NOT NULL, + state TEXT NOT NULL, + scope TEXT NOT NULL, + PRIMARY KEY (view_gen, token) +) WITHOUT ROWID +; + +CREATE TABLE generation_contract_input_state ( + view_gen INTEGER NOT NULL, + repo_prefix TEXT NOT NULL, + checkout_id TEXT NOT NULL, + input_version TEXT NOT NULL, + input_fingerprint TEXT NOT NULL, + previous_input_version TEXT NOT NULL, + previous_input_fingerprint TEXT NOT NULL, + accepted INTEGER NOT NULL CHECK(accepted IN (0,1)), + PRIMARY KEY(view_gen, repo_prefix, checkout_id) +) WITHOUT ROWID +; + +CREATE TABLE generation_contract_boundary_receipt ( + view_gen INTEGER NOT NULL,repo_prefix TEXT NOT NULL,checkout_id TEXT NOT NULL,file_path TEXT NOT NULL, + version TEXT NOT NULL,fingerprint TEXT NOT NULL,source_fingerprint TEXT NOT NULL,accepted INTEGER NOT NULL, + receipt BLOB NOT NULL, + PRIMARY KEY(view_gen,repo_prefix,checkout_id,file_path) +) WITHOUT ROWID +; + +CREATE TABLE generation_contract_boundary_keys ( + view_gen INTEGER NOT NULL,key_kind TEXT NOT NULL,lookup_key TEXT NOT NULL, + repo_prefix TEXT NOT NULL,checkout_id TEXT NOT NULL,file_path TEXT NOT NULL, + PRIMARY KEY(view_gen,key_kind,lookup_key,repo_prefix,checkout_id,file_path) +) WITHOUT ROWID +; + +CREATE TABLE generation_contract_boundary_baseline ( + view_gen INTEGER NOT NULL,repo_prefix TEXT NOT NULL,checkout_id TEXT NOT NULL, + version TEXT NOT NULL,fingerprint TEXT NOT NULL, + PRIMARY KEY(view_gen,repo_prefix,checkout_id) +) WITHOUT ROWID +; + +CREATE TABLE contract_attachments ( + repo_prefix TEXT NOT NULL, + checkout_id TEXT NOT NULL, + input_version TEXT NOT NULL, + input_fingerprint TEXT NOT NULL, + payload_generation INTEGER NOT NULL, + PRIMARY KEY(repo_prefix, checkout_id, input_version, input_fingerprint) +) WITHOUT ROWID +; + +CREATE TABLE contract_attachment_work ( + repo_prefix TEXT NOT NULL, + checkout_id TEXT NOT NULL, + work_checkout_id TEXT NOT NULL, + attachment_version TEXT NOT NULL, + attachment_fingerprint TEXT NOT NULL, + token TEXT NOT NULL, + origin_generation INTEGER NOT NULL, + file_path TEXT NOT NULL, + input_version TEXT NOT NULL, + input_fingerprint TEXT NOT NULL, + scope TEXT NOT NULL, + PRIMARY KEY(repo_prefix, checkout_id, attachment_version, attachment_fingerprint, token) +) WITHOUT ROWID +; + +CREATE TABLE contract_attachment_inputs ( + repo_prefix TEXT NOT NULL,checkout_id TEXT NOT NULL,input_version TEXT NOT NULL,input_fingerprint TEXT NOT NULL, + source_generation INTEGER NOT NULL,source_repo TEXT NOT NULL,source_checkout TEXT NOT NULL,source_version TEXT NOT NULL,source_fingerprint TEXT NOT NULL,source_accepted INTEGER NOT NULL,source_found INTEGER NOT NULL,source_effective INTEGER NOT NULL, + PRIMARY KEY(repo_prefix,checkout_id,input_version,input_fingerprint,source_generation,source_repo,source_checkout) +) WITHOUT ROWID +; + +CREATE TABLE generation_input_manifest ( + view_gen INTEGER NOT NULL, + file_path TEXT NOT NULL, + entry_state TEXT NOT NULL, + admission TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT '', + content_sha256 TEXT NOT NULL DEFAULT '', + PRIMARY KEY (view_gen, file_path) +) WITHOUT ROWID +; + +CREATE TABLE generation_input_manifest_meta ( + view_gen INTEGER PRIMARY KEY, + manifest_version INTEGER NOT NULL, + is_full INTEGER NOT NULL, + entry_count INTEGER NOT NULL, + policy_digest TEXT NOT NULL +) +; + +CREATE TABLE generation_derivation_stamps ( + view_gen INTEGER NOT NULL, + pass TEXT NOT NULL, + derivation_version INTEGER NOT NULL, + stamped_at INTEGER NOT NULL, + PRIMARY KEY (view_gen, pass) +) WITHOUT ROWID +; + +CREATE TABLE analysis_generations ( + generation_id INTEGER PRIMARY KEY AUTOINCREMENT, + format_version INTEGER NOT NULL, + build_revision INTEGER NOT NULL, + created_at_unix INTEGER NOT NULL, + state INTEGER NOT NULL CHECK (state IN (0, 1, 2)), + node_count INTEGER NOT NULL CHECK (node_count >= 0), + community_count INTEGER NOT NULL CHECK (community_count >= 0), + process_count INTEGER NOT NULL CHECK (process_count >= 0), + concept_count INTEGER NOT NULL CHECK (concept_count >= 0), + pagerank_max REAL NOT NULL DEFAULT 0, + authority_max REAL NOT NULL DEFAULT 0, + hub_max REAL NOT NULL DEFAULT 0, + modularity REAL NOT NULL DEFAULT 0, + processes_truncated INTEGER NOT NULL DEFAULT 0 CHECK (processes_truncated IN (0, 1)), + processes_truncation_reason TEXT NOT NULL DEFAULT '', + view_gen INTEGER NOT NULL DEFAULT 0 +) +; + +CREATE TABLE analysis_active_generation ( + view_gen INTEGER NOT NULL DEFAULT 0, + slot INTEGER NOT NULL CHECK (slot = 1), + generation_id INTEGER NOT NULL UNIQUE + REFERENCES analysis_generations(generation_id) ON DELETE RESTRICT, + PRIMARY KEY (view_gen, slot) +) +; + +CREATE TABLE analysis_generation_components ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + component TEXT NOT NULL, + row_count INTEGER NOT NULL CHECK (row_count >= 0), + sealed_at_unix INTEGER NOT NULL, + PRIMARY KEY (generation_id, component) +) WITHOUT ROWID +; + +CREATE TABLE analysis_communities ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + community_id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + hub TEXT NOT NULL DEFAULT '', + parent_id TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL CHECK (size >= 0), + cohesion REAL NOT NULL DEFAULT 0, + PRIMARY KEY (generation_id, community_id) +) WITHOUT ROWID +; + +CREATE TABLE analysis_community_files ( + generation_id INTEGER NOT NULL, + community_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + file_path TEXT NOT NULL, + PRIMARY KEY (generation_id, community_id, ordinal), + FOREIGN KEY (generation_id, community_id) + REFERENCES analysis_communities(generation_id, community_id) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_nodes ( + id INTEGER PRIMARY KEY, + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + node_id TEXT NOT NULL, + community_id TEXT, + pagerank REAL NOT NULL DEFAULT 0, + authority REAL NOT NULL DEFAULT 0, + hub REAL NOT NULL DEFAULT 0, + UNIQUE (generation_id, node_id), + FOREIGN KEY (generation_id, community_id) + REFERENCES analysis_communities(generation_id, community_id) +) +; + +CREATE TABLE analysis_processes ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + process_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + entry_point TEXT NOT NULL DEFAULT '', + step_count INTEGER NOT NULL CHECK (step_count >= 0), + score REAL NOT NULL DEFAULT 0, + truncated INTEGER NOT NULL DEFAULT 0 CHECK (truncated IN (0, 1)), + PRIMARY KEY (generation_id, process_id) +) WITHOUT ROWID +; + +CREATE TABLE analysis_process_files ( + generation_id INTEGER NOT NULL, + process_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + file_path TEXT NOT NULL, + PRIMARY KEY (generation_id, process_id, ordinal), + FOREIGN KEY (generation_id, process_id) + REFERENCES analysis_processes(generation_id, process_id) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_process_steps ( + generation_id INTEGER NOT NULL, + process_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + node_rowid INTEGER NOT NULL + REFERENCES analysis_nodes(id), + depth INTEGER NOT NULL CHECK (depth >= 0), + PRIMARY KEY (generation_id, process_id, ordinal), + FOREIGN KEY (generation_id, process_id) + REFERENCES analysis_processes(generation_id, process_id) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_concepts ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + token TEXT NOT NULL, + in_vocabulary INTEGER NOT NULL CHECK (in_vocabulary IN (0, 1)), + PRIMARY KEY (generation_id, token) +) WITHOUT ROWID +; + +CREATE TABLE analysis_concept_relations ( + generation_id INTEGER NOT NULL, + token TEXT NOT NULL, + related_token TEXT NOT NULL, + rank INTEGER NOT NULL CHECK (rank >= 0), + PRIMARY KEY (generation_id, token, rank, related_token), + FOREIGN KEY (generation_id, token) + REFERENCES analysis_concepts(generation_id, token) ON DELETE CASCADE, + FOREIGN KEY (generation_id, related_token) + REFERENCES analysis_concepts(generation_id, token) ON DELETE CASCADE +) WITHOUT ROWID +; + +CREATE TABLE analysis_blobs ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + component TEXT NOT NULL, + payload BLOB NOT NULL, + PRIMARY KEY (generation_id, component) +) WITHOUT ROWID +; + +CREATE TABLE repository_families ( + family_id TEXT PRIMARY KEY, + common_dir_identity TEXT NOT NULL UNIQUE, + display_remote TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL, + primary_epoch INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + last_seen INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID +; + +CREATE TABLE checkouts ( + checkout_id TEXT PRIMARY KEY, + incarnation TEXT NOT NULL, + family_id TEXT NOT NULL + REFERENCES repository_families(family_id) ON DELETE RESTRICT, + root_path TEXT NOT NULL, + git_dir TEXT NOT NULL, + admin_name TEXT NOT NULL, + state TEXT NOT NULL, + desired_mode TEXT NOT NULL, + effective_mode TEXT NOT NULL, + locked INTEGER NOT NULL DEFAULT 0, + prunable INTEGER NOT NULL DEFAULT 0, + head_ref TEXT NOT NULL DEFAULT '', + head_commit TEXT NOT NULL DEFAULT '', + head_tree TEXT NOT NULL DEFAULT '', + last_accessible INTEGER NOT NULL DEFAULT 0, + unavailable_since INTEGER NOT NULL DEFAULT 0, + availability_deadline INTEGER NOT NULL DEFAULT 0, + removal_detected_at INTEGER NOT NULL DEFAULT 0, + removal_deadline INTEGER NOT NULL DEFAULT 0, + removal_evidence TEXT NOT NULL DEFAULT '', + active_intent_transition_id TEXT, + last_seen INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + UNIQUE (family_id, admin_name, incarnation) +) WITHOUT ROWID +; + +CREATE TABLE tracking_intents ( + intent_id TEXT PRIMARY KEY, + checkout_id TEXT NOT NULL REFERENCES checkouts(checkout_id) ON DELETE CASCADE, + source_kind TEXT NOT NULL, + source_locator TEXT NOT NULL, + active INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + revoked_at INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + UNIQUE (checkout_id, source_kind, source_locator) +) WITHOUT ROWID +; + +CREATE TABLE intent_transitions ( + transition_id TEXT PRIMARY KEY, + checkout_id TEXT NOT NULL UNIQUE + REFERENCES checkouts(checkout_id) ON DELETE CASCADE, + cause TEXT NOT NULL, + prior_desired_mode TEXT, + prior_effective_mode TEXT, + requested_mode TEXT, + prior_checkout_state TEXT, + source_snapshot_hash TEXT, + state TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + last_progress INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE checkout_path_evidence ( + checkout_id TEXT PRIMARY KEY + REFERENCES checkouts(checkout_id) ON DELETE CASCADE, + root_path_identity TEXT NOT NULL DEFAULT '', + root_volume_kind TEXT NOT NULL DEFAULT '', + root_volume_token TEXT NOT NULL DEFAULT '', + nearest_existing_ancestor_path TEXT NOT NULL DEFAULT '', + ancestor_volume_kind TEXT NOT NULL DEFAULT '', + ancestor_volume_token TEXT NOT NULL DEFAULT '', + common_dir_volume_kind TEXT NOT NULL DEFAULT '', + common_dir_volume_token TEXT NOT NULL DEFAULT '', + sampled_at INTEGER NOT NULL DEFAULT 0, + sample_generation INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID +; + +CREATE TABLE dedicated_graphs ( + graph_id TEXT PRIMARY KEY, + owner_checkout_id TEXT UNIQUE + REFERENCES checkouts(checkout_id) ON DELETE RESTRICT, + repo_prefix TEXT NOT NULL UNIQUE, + family_id TEXT NOT NULL + REFERENCES repository_families(family_id) ON DELETE RESTRICT, + is_primary_base INTEGER NOT NULL DEFAULT 0, + active_generation_id INTEGER, + state TEXT NOT NULL +) WITHOUT ROWID +; + +CREATE TABLE view_generations ( + generation_id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_kind TEXT NOT NULL, + graph_id TEXT NOT NULL DEFAULT '', + layer_id TEXT, + checkout_id TEXT, + generation_kind TEXT NOT NULL, + base_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + lower_view_fingerprint TEXT NOT NULL DEFAULT '', + tree_oid TEXT NOT NULL DEFAULT '', + provenance_commit_oid TEXT, + config_hash TEXT NOT NULL DEFAULT '', + extractor_versions TEXT NOT NULL DEFAULT '', + resolver_version TEXT NOT NULL DEFAULT '', + dependency_revision TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL, + covered_files INTEGER NOT NULL DEFAULT 0, + affected_files INTEGER NOT NULL DEFAULT 0, + storage_bytes INTEGER NOT NULL DEFAULT 0, + completeness TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT 0, + published_at INTEGER NOT NULL DEFAULT 0, + last_selected INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '' +) +; + +CREATE TABLE view_layers ( + layer_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + graph_id TEXT NOT NULL, + checkout_id TEXT, + target_ref TEXT, + target_commit TEXT NOT NULL DEFAULT '', + target_tree TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE checkout_routes ( + checkout_id TEXT PRIMARY KEY + REFERENCES checkouts(checkout_id) ON DELETE RESTRICT, + graph_id TEXT NOT NULL, + commit_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + dirty_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + route_epoch INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL +) WITHOUT ROWID +; + +CREATE TABLE ref_views ( + ref_view_id TEXT PRIMARY KEY, + graph_id TEXT NOT NULL, + selector_kind TEXT NOT NULL, + selector_value TEXT NOT NULL, + desired_ref TEXT NOT NULL DEFAULT '', + desired_commit TEXT NOT NULL DEFAULT '', + desired_tree TEXT NOT NULL DEFAULT '', + active_generation_id INTEGER + REFERENCES view_generations(generation_id) ON DELETE RESTRICT, + active_ref TEXT, + active_commit TEXT, + active_tree TEXT, + enrichment_profile TEXT NOT NULL, + desired_build_fingerprint TEXT NOT NULL DEFAULT '', + active_build_fingerprint TEXT, + route_epoch INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL, + exact_view INTEGER NOT NULL DEFAULT 1, + last_resolved INTEGER NOT NULL DEFAULT 0, + last_selected INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + UNIQUE (graph_id, selector_kind, selector_value, enrichment_profile) +) WITHOUT ROWID +; + +CREATE TABLE ref_view_builds ( + build_id TEXT PRIMARY KEY, + ref_view_id TEXT NOT NULL REFERENCES ref_views(ref_view_id) ON DELETE CASCADE, + desired_ref TEXT NOT NULL DEFAULT '', + desired_commit TEXT NOT NULL DEFAULT '', + desired_tree TEXT NOT NULL DEFAULT '', + base_generation_id INTEGER, + enrichment_profile TEXT NOT NULL DEFAULT '', + build_fingerprint TEXT NOT NULL, + generation_id INTEGER, + captured_route_epoch INTEGER NOT NULL, + state TEXT NOT NULL, + build_token TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL DEFAULT 0, + last_progress INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE cleanup_journal ( + cleanup_id TEXT PRIMARY KEY, + opaque_target_ids TEXT NOT NULL, + reason TEXT NOT NULL, + phase TEXT NOT NULL, + grace_deadline INTEGER NOT NULL DEFAULT 0, + primary_epoch INTEGER NOT NULL DEFAULT 0, + last_progress INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE dedicated_base_publications ( + graph_id TEXT PRIMARY KEY REFERENCES dedicated_graphs(graph_id) ON DELETE CASCADE, + owner_checkout_id TEXT NOT NULL, + owner_incarnation TEXT NOT NULL, + owner_generation_floor INTEGER NOT NULL, + repo_prefix TEXT NOT NULL, + family_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + authority_token TEXT NOT NULL, + desired_epoch INTEGER NOT NULL DEFAULT 0, + tree_oid TEXT NOT NULL DEFAULT '', + config_hash TEXT NOT NULL DEFAULT '', + extractor_versions TEXT NOT NULL DEFAULT '', + resolver_version TEXT NOT NULL DEFAULT '', + dependency_revision TEXT NOT NULL DEFAULT '', + attempt_token TEXT NOT NULL DEFAULT '', + attempt_state TEXT NOT NULL DEFAULT 'idle', + generation_id INTEGER NOT NULL DEFAULT 0, + base_generation_id INTEGER NOT NULL DEFAULT 0, + layer_id TEXT NOT NULL DEFAULT '', + lower_view_fingerprint TEXT NOT NULL DEFAULT '', + expected_active_generation_id INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID +; + +CREATE TABLE generation_row_counts ( + view_gen INTEGER PRIMARY KEY, + nodes INTEGER NOT NULL DEFAULT 0, + edges INTEGER NOT NULL DEFAULT 0 +) +; + +CREATE TABLE generation_row_counts_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + seeded INTEGER NOT NULL, + installed_at INTEGER NOT NULL, + seeded_at INTEGER NOT NULL DEFAULT 0 +) +; + +CREATE TABLE symbol_fts_generation_revisions ( + view_gen INTEGER PRIMARY KEY, + rev INTEGER NOT NULL +) +; + +CREATE INDEX generation_contract_work_scope + ON generation_contract_work(view_gen, repo_prefix, checkout_id, token) +; + +CREATE INDEX contract_attachment_payload ON contract_attachments(payload_generation) +; + +CREATE INDEX contract_input_identity ON generation_contract_input_state(repo_prefix, checkout_id, input_version, input_fingerprint) +; + +CREATE INDEX contract_work_pending_scope ON generation_contract_work(view_gen,repo_prefix,checkout_id,state,token) +; + +CREATE INDEX contract_attachment_work_token ON contract_attachment_work(repo_prefix, work_checkout_id, token) +; + +CREATE INDEX contract_attachment_input_source ON contract_attachment_inputs(source_repo,source_version,source_fingerprint,source_found,source_effective) +; + +CREATE INDEX contract_boundary_keys_file ON generation_contract_boundary_keys(view_gen,repo_prefix,checkout_id,file_path,key_kind,lookup_key) +; + +CREATE INDEX contract_boundary_receipt_stamps ON generation_contract_boundary_receipt(view_gen,repo_prefix,checkout_id,file_path,fingerprint,source_fingerprint,accepted) +; + +CREATE INDEX analysis_generations_by_state + ON analysis_generations(state, generation_id DESC) +; + +CREATE INDEX analysis_nodes_by_pagerank + ON analysis_nodes(generation_id, pagerank DESC, id ASC) +; + +CREATE INDEX analysis_nodes_by_authority + ON analysis_nodes(generation_id, authority DESC, id ASC) +; + +CREATE INDEX analysis_nodes_by_hub + ON analysis_nodes(generation_id, hub DESC, id ASC) +; + +CREATE INDEX analysis_nodes_by_community + ON analysis_nodes(generation_id, community_id, node_id) +; + +CREATE INDEX analysis_process_step_node_fk + ON analysis_process_steps(node_rowid) +; + +CREATE INDEX analysis_concept_relations_by_related + ON analysis_concept_relations(generation_id, related_token, rank, token) +; + +CREATE UNIQUE INDEX dedicated_graphs_primary_base + ON dedicated_graphs(family_id) WHERE is_primary_base = 1 +; + +CREATE INDEX view_generations_by_graph_state + ON view_generations(graph_id, state, generation_id DESC) +; + +CREATE INDEX view_generations_by_base + ON view_generations(base_generation_id) WHERE base_generation_id IS NOT NULL +; + +CREATE INDEX checkout_routes_by_commit_generation + ON checkout_routes(commit_generation_id) WHERE commit_generation_id IS NOT NULL +; + +CREATE INDEX checkout_routes_by_dirty_generation + ON checkout_routes(dirty_generation_id) WHERE dirty_generation_id IS NOT NULL +; + +CREATE INDEX ref_views_by_active_generation + ON ref_views(active_generation_id) WHERE active_generation_id IS NOT NULL +; + +CREATE UNIQUE INDEX ref_view_builds_single_inflight + ON ref_view_builds(ref_view_id, desired_tree, base_generation_id, build_fingerprint) + WHERE state = 'building' +; + +CREATE INDEX nodes_by_repo_kind ON nodes(repo_prefix, kind) +; + +CREATE INDEX nodes_by_generation ON nodes(view_gen, id) +; + +CREATE INDEX nodes_name_candidates ON nodes(view_gen, repo_prefix, length(name), id, name, kind, file_path, workspace_id, project_id) WHERE name <> '' +; + +CREATE INDEX edges_by_generation ON edges(view_gen, id) +; + +CREATE INDEX edges_contract_owner_generation ON edges(view_gen, kind) WHERE kind IN ('provides','consumes','handles_route') +; + +CREATE INDEX nodes_contract_seed_candidates ON nodes(repo_prefix,kind,view_gen,id,language,meta) WHERE kind IN ('field','type','interface','method') +; + +CREATE INDEX nodes_repo_files ON nodes(repo_prefix, workspace_id, language, file_path, id) WHERE kind = 'file' +; + +CREATE INDEX edges_by_unresolved ON edges(is_unresolved) WHERE is_unresolved = 1 +; + +CREATE INDEX edges_fnvalue_prefixed ON edges(view_gen, to_id) WHERE to_id LIKE '%::unresolved::fnvalue::%' +; + +CREATE INDEX nodes_go_receiver_type ON nodes(repo_prefix, file_dir, name, id) WHERE language = 'go' AND kind IN ('type', 'interface') AND name <> '' AND file_path <> '' +; + +CREATE INDEX nodes_by_qual ON nodes(qual_name) WHERE qual_name <> '' +; + +CREATE INDEX edges_external ON edges(kind) WHERE (to_id GLOB 'dep::*' OR to_id GLOB 'external::*' OR to_id GLOB 'stdlib::*' OR to_id GLOB '*::stdlib::*' OR to_id GLOB 'external-call::*') +; + +CREATE INDEX nodes_missing_workspace_slugs ON nodes(repo_prefix) + WHERE workspace_id = '' OR project_id = '' +; + +CREATE INDEX clone_shingles_by_repo + ON clone_shingles(view_gen, repo_prefix, node_id) +; + +CREATE INDEX constant_values_by_file ON constant_values(view_gen, repo_prefix, file_path) +; + +CREATE INDEX files_with_errors ON files(view_gen, repo_prefix) WHERE errors <> '' +; + +CREATE INDEX ref_facts_by_file ON ref_facts(view_gen, repo_prefix, file_path) +; + +CREATE INDEX ref_facts_by_target ON ref_facts(view_gen, repo_prefix, to_id) +; + +CREATE INDEX vectors_by_repo ON vectors(view_gen, repo_prefix, node_id) +; + +CREATE INDEX churn_by_repo ON churn_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE INDEX coverage_by_repo ON coverage_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE INDEX release_by_repo ON release_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE INDEX blame_by_repo ON blame_enrichment(view_gen, repo_prefix) WHERE repo_prefix <> '' +; + +CREATE UNIQUE INDEX symbol_fts_rowid_by_rowid + ON symbol_fts_rowid(fts_rowid) +; + +CREATE INDEX symbol_fts_rowid_by_repo + ON symbol_fts_rowid(view_gen, repo_prefix, fts_rowid) +; + +CREATE INDEX symbol_fts_rowid_by_generation + ON symbol_fts_rowid(view_gen, fts_rowid) +; + +CREATE INDEX content_fts_rowid_by_repo_file + ON content_fts_rowid(view_gen, repo_prefix, file_path, fts_rowid) +; + +CREATE INDEX content_fts_rowid_by_file + ON content_fts_rowid(view_gen, file_path, fts_rowid) +; + +CREATE INDEX nodes_stats_histogram ON nodes(view_gen, kind, language) +; + +CREATE INDEX nodes_by_name ON nodes(name, view_gen, id, repo_prefix, kind, file_path) +; + +CREATE INDEX nodes_by_kind ON nodes(kind, view_gen) +; + +CREATE INDEX nodes_by_file ON nodes(file_path, view_gen) +; + +CREATE INDEX nodes_by_repo ON nodes(repo_prefix, view_gen) +; + +CREATE INDEX nodes_by_effective_workspace ON nodes(COALESCE(NULLIF(workspace_id, ''), repo_prefix), view_gen, kind, id) +; + +CREATE INDEX nodes_deadcode_eligible_workspace ON nodes(COALESCE(NULLIF(workspace_id, ''), repo_prefix), view_gen, kind, id, external, workspace_id, repo_prefix) WHERE external IS NOT 1 +; + +CREATE INDEX nodes_by_repo_language_name ON nodes(repo_prefix, language, name, view_gen) WHERE name <> '' +; + +CREATE INDEX edges_by_from ON edges(view_gen, from_id, kind) +; + +CREATE INDEX edges_by_from_line ON edges(view_gen, from_id, line) +; + +CREATE INDEX edges_by_from_line_kind ON edges(view_gen, from_id, line, kind) +; + +CREATE INDEX edges_by_to ON edges(view_gen, to_id, kind) +; + +CREATE INDEX edges_by_kind ON edges(kind) +; + +CREATE INDEX edges_by_file ON edges(file_path, kind) +; + +CREATE TRIGGER generation_row_counts_edges_delete AFTER DELETE ON edges BEGIN INSERT INTO generation_row_counts(view_gen, edges) VALUES (OLD.view_gen, -1) ON CONFLICT(view_gen) DO UPDATE SET edges = edges + (-1); END +; + +CREATE TRIGGER generation_row_counts_edges_insert AFTER INSERT ON edges BEGIN INSERT INTO generation_row_counts(view_gen, edges) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET edges = edges + (1); END +; + +CREATE TRIGGER generation_row_counts_edges_move AFTER UPDATE OF view_gen ON edges WHEN OLD.view_gen IS NOT NEW.view_gen BEGIN INSERT INTO generation_row_counts(view_gen, edges) VALUES (OLD.view_gen, -1) ON CONFLICT(view_gen) DO UPDATE SET edges = edges + (-1); INSERT INTO generation_row_counts(view_gen, edges) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET edges = edges + (1); END +; + +CREATE TRIGGER generation_row_counts_nodes_delete AFTER DELETE ON nodes BEGIN INSERT INTO generation_row_counts(view_gen, nodes) VALUES (OLD.view_gen, -1) ON CONFLICT(view_gen) DO UPDATE SET nodes = nodes + (-1); END +; + +CREATE TRIGGER generation_row_counts_nodes_insert AFTER INSERT ON nodes BEGIN INSERT INTO generation_row_counts(view_gen, nodes) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET nodes = nodes + (1); END +; + +CREATE TRIGGER generation_row_counts_nodes_move AFTER UPDATE OF view_gen ON nodes WHEN OLD.view_gen IS NOT NEW.view_gen BEGIN INSERT INTO generation_row_counts(view_gen, nodes) VALUES (OLD.view_gen, -1) ON CONFLICT(view_gen) DO UPDATE SET nodes = nodes + (-1); INSERT INTO generation_row_counts(view_gen, nodes) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET nodes = nodes + (1); END +; + +CREATE TRIGGER symbol_fts_generation_revisions_delete AFTER DELETE ON symbol_fts_rowid BEGIN INSERT INTO symbol_fts_generation_revisions(view_gen, rev) VALUES (OLD.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET rev = rev + 1; END +; + +CREATE TRIGGER symbol_fts_generation_revisions_insert AFTER INSERT ON symbol_fts_rowid BEGIN INSERT INTO symbol_fts_generation_revisions(view_gen, rev) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET rev = rev + 1; END +; + +CREATE TRIGGER symbol_fts_generation_revisions_update AFTER UPDATE ON symbol_fts_rowid BEGIN INSERT INTO symbol_fts_generation_revisions(view_gen, rev) VALUES (OLD.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET rev = rev + 1; INSERT INTO symbol_fts_generation_revisions(view_gen, rev) VALUES (NEW.view_gen, 1) ON CONFLICT(view_gen) DO UPDATE SET rev = rev + 1; END +; diff --git a/internal/graph/store_sqlite/upgrade_backup.go b/internal/graph/store_sqlite/upgrade_backup.go new file mode 100644 index 000000000..c186ce784 --- /dev/null +++ b/internal/graph/store_sqlite/upgrade_backup.go @@ -0,0 +1,329 @@ +package store_sqlite + +import ( + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "time" +) + +// Pre-upgrade backups. +// +// An older binary refuses a newer store (SchemaTooNewError), so once a store +// is upgraded the only way back to the previous release is a full reindex. +// When the caller holds the store lock and the volume has room, Open first +// copies the store to .v.backup: a single self-contained SQLite +// file at the old schema version that the previous release opens as-is +// after it is renamed back over the store. +// +// The copy is written as .partial and renamed into place only once +// it is complete and durable, so a finalized name never holds a torn copy. +// A partial left by a crash is removed by the next upgrade attempt or by the +// daemon's start-up prune, both of which run under the store lock. + +// UpgradeBackupRetention is how long the daemon keeps a pre-upgrade backup +// before its start-up prune removes it. +const UpgradeBackupRetention = 14 * 24 * time.Hour + +const ( + upgradeBackupSuffix = ".backup" + upgradeBackupPartialSuffix = ".partial" +) + +// UpgradeBackupPath names the backup Open takes of storePath before +// upgrading it from schema version. +func UpgradeBackupPath(storePath string, version int) string { + return storePath + ".v" + strconv.Itoa(version) + upgradeBackupSuffix +} + +// UpgradeBackup is one finalized pre-upgrade backup on disk. +type UpgradeBackup struct { + Path string + Version int // schema version the backed-up store had + Size int64 + ModTime time.Time +} + +// ListUpgradeBackups returns the finalized backups next to storePath, +// ordered by schema version. Interrupted partial copies are not listed. +func ListUpgradeBackups(storePath string) ([]UpgradeBackup, error) { + backups, _, err := scanUpgradeBackups(storePath) + return backups, err +} + +// scanUpgradeBackups lists the finalized backups of storePath and the +// partial copies an interrupted backup left next to it. +func scanUpgradeBackups(storePath string) (backups []UpgradeBackup, partials []string, err error) { + dir := filepath.Dir(storePath) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil, nil + } + return nil, nil, err + } + prefix := filepath.Base(storePath) + ".v" + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasPrefix(name, prefix) { + continue + } + // A partial is recognised by any file of its set, so a crash that + // removed the main copy but not its WAL still leaves it findable. + partialSet := name + for _, companion := range []string{"-wal", "-shm", "-journal"} { + partialSet = strings.TrimSuffix(partialSet, companion) + } + partial := strings.HasSuffix(partialSet, upgradeBackupSuffix+upgradeBackupPartialSuffix) + if partial { + name = partialSet + } else if !strings.HasSuffix(name, upgradeBackupSuffix) { + continue + } + digits := strings.TrimSuffix(strings.TrimSuffix(strings.TrimPrefix(name, prefix), upgradeBackupPartialSuffix), upgradeBackupSuffix) + version, err := strconv.Atoi(digits) + if err != nil || version <= 0 || strconv.Itoa(version) != digits { + continue + } + if partial { + if path := filepath.Join(dir, name); !slices.Contains(partials, path) { + partials = append(partials, path) + } + continue + } + info, err := entry.Info() + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, nil, err + } + backups = append(backups, UpgradeBackup{Path: filepath.Join(dir, name), Version: version, Size: info.Size(), ModTime: info.ModTime()}) + } + slices.SortFunc(backups, func(a, b UpgradeBackup) int { return a.Version - b.Version }) + return backups, partials, nil +} + +// RemoveUpgradeBackups deletes every finalized backup of storePath and +// returns the ones it removed. It leaves partial copies alone: one may be +// in the middle of being written by a daemon that holds the store lock. +func RemoveUpgradeBackups(storePath string) ([]UpgradeBackup, error) { + backups, err := ListUpgradeBackups(storePath) + if err != nil { + return nil, err + } + var removed []UpgradeBackup + var errs []error + for _, b := range backups { + if err := removeSQLiteFileSet(b.Path); err != nil { + errs = append(errs, err) + continue + } + removed = append(removed, b) + } + return removed, errors.Join(errs...) +} + +// PruneUpgradeBackups deletes the backups of storePath older than maxAge at +// now, plus any partial copy an interrupted upgrade left behind, and returns +// the paths it removed. The caller must hold the store lock: partial copies +// are only ever written under it, so holding it proves none is in progress. +func PruneUpgradeBackups(storePath string, maxAge time.Duration, now time.Time) ([]string, error) { + backups, partials, err := scanUpgradeBackups(storePath) + if err != nil { + return nil, err + } + var removed []string + var errs []error + for _, b := range backups { + if now.Sub(b.ModTime) <= maxAge { + continue + } + if err := removeSQLiteFileSet(b.Path); err != nil { + errs = append(errs, err) + continue + } + removed = append(removed, b.Path) + } + for _, partial := range partials { + if err := removeSQLiteFileSet(partial); err != nil { + errs = append(errs, err) + continue + } + removed = append(removed, partial) + } + return removed, errors.Join(errs...) +} + +// removeSQLiteFileSet deletes a database file and the companions SQLite may +// have created next to it. A missing file is not an error. +func removeSQLiteFileSet(path string) error { + var errs []error + for _, suffix := range []string{"", "-wal", "-shm", "-journal"} { + if err := os.Remove(path + suffix); err != nil && !os.IsNotExist(err) { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// backupBeforeUpgrade snapshots the store the pending upgrade described by +// space is about to change. It never fails Open: a backup that cannot be +// taken is skipped with the reason reported, and the upgrade proceeds. +func backupBeforeUpgrade(space upgradeSpace, observe MigrationObserver, hook upgradeHook) { + final := UpgradeBackupPath(space.path, space.from) + started := time.Now() + report := func(phase MigrationPhase, detail string) { + progress := MigrationProgress{Version: space.from, Name: "pre-upgrade backup", Phase: phase, Elapsed: time.Since(started), Detail: detail} + if observe != nil { + observe(progress) + return + } + log.Printf("store_sqlite: upgrade backup phase=%s from=v%d to=v%d elapsed=%s detail=%q", phase, space.from, space.to, progress.Elapsed.Round(time.Millisecond), detail) + } + if err := removeSQLiteFileSet(final + upgradeBackupPartialSuffix); err != nil { + report(MigrationBackupSkipped, fmt.Sprintf("could not clear an interrupted backup: %v", err)) + return + } + if _, err := os.Stat(final); err == nil { + // A restart after an interrupted upgrade must not replace the + // snapshot of the store as it was before the first attempt. + report(MigrationBackupKept, final) + return + } + copyBytes := space.stats.mainBytes + space.stats.walBytes + if space.measured && space.available < space.estimate.Need+copyBytes { + report(MigrationBackupSkipped, fmt.Sprintf( + "%d bytes available cover the upgrade (%d bytes) but not also a %d-byte copy of the store", + space.available, space.estimate.Need, copyBytes)) + return + } + if err := writeUpgradeBackup(space.path, final, func() { hook.at(upgradeBackupCopied, space.from) }); err != nil { + report(MigrationBackupSkipped, fmt.Sprintf("backup failed: %v", err)) + return + } + report(MigrationBackupCreated, final) +} + +// writeUpgradeBackup copies the quiescent store (main file plus any WAL) to +// final's partial name, folds the WAL into a single self-contained file, and +// renames it into place durably. Every path out on error removes the partial. +// copied runs once both files are in the partial copy. +func writeUpgradeBackup(storePath, final string, copied func()) (err error) { + partial := final + upgradeBackupPartialSuffix + defer func() { + if err != nil { + err = errors.Join(err, removeSQLiteFileSet(partial)) + } + }() + if err := copyFileDurably(storePath, partial); err != nil { + return err + } + if err := copyFileDurably(storePath+"-wal", partial+"-wal"); err != nil && !os.IsNotExist(err) { + return err + } + copied() + if err := foldBackupWAL(partial); err != nil { + return err + } + if err := os.Rename(partial, final); err != nil { + return err + } + return syncDir(filepath.Dir(final)) +} + +// copyFileDurably copies src to a new file dst with src's permissions and +// fsyncs it. A missing src is reported as such before dst is created. +func copyFileDurably(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + info, err := in.Stat() + if err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + if err := out.Sync(); err != nil { + _ = out.Close() + return err + } + return out.Close() +} + +// foldBackupWAL checkpoints a copied WAL into the copy's main file and +// switches the copy out of WAL mode, so the backup is one file that stays +// complete even if it is later moved without its companions. +func foldBackupWAL(path string) error { + db, err := openSQLiteStoreDB(sqliteDSN(path, sqliteBusyPragma)) + if err != nil { + return err + } + db.SetMaxOpenConns(1) + var busy, frames, checkpointed int + if err := db.QueryRow("PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &frames, &checkpointed); err != nil { + _ = db.Close() + return fmt.Errorf("fold backup WAL: %w", err) + } + if busy != 0 { + _ = db.Close() + return errors.New("fold backup WAL: checkpoint blocked") + } + var mode string + if err := db.QueryRow("PRAGMA journal_mode=DELETE").Scan(&mode); err != nil { + _ = db.Close() + return fmt.Errorf("fold backup WAL: %w", err) + } + if err := db.Close(); err != nil { + return err + } + if !strings.EqualFold(mode, "delete") { + return fmt.Errorf("fold backup WAL: journal mode stayed %q", mode) + } + for _, suffix := range []string{"-wal", "-shm"} { + if err := os.Remove(path + suffix); err != nil && !os.IsNotExist(err) { + return err + } + } + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +// syncDir makes a rename in dir durable. Windows has no directory fsync; +// its rename is journaled by the filesystem instead. +func syncDir(dir string) error { + if runtime.GOOS == "windows" { + return nil + } + d, err := os.Open(dir) + if err != nil { + return err + } + if err := d.Sync(); err != nil { + _ = d.Close() + return err + } + return d.Close() +} diff --git a/internal/graph/store_sqlite/upgrade_backup_test.go b/internal/graph/store_sqlite/upgrade_backup_test.go new file mode 100644 index 000000000..7c2c5fcc4 --- /dev/null +++ b/internal/graph/store_sqlite/upgrade_backup_test.go @@ -0,0 +1,253 @@ +package store_sqlite + +import ( + "database/sql" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type migrationEvents struct { + mu sync.Mutex + events []MigrationProgress +} + +func (m *migrationEvents) observe(p MigrationProgress) { + m.mu.Lock() + defer m.mu.Unlock() + m.events = append(m.events, p) +} + +func (m *migrationEvents) phases() []MigrationPhase { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]MigrationPhase, 0, len(m.events)) + for _, e := range m.events { + out = append(out, e.Phase) + } + return out +} + +func (m *migrationEvents) only(t *testing.T, phase MigrationPhase) MigrationProgress { + t.Helper() + m.mu.Lock() + defer m.mu.Unlock() + var found []MigrationProgress + for _, e := range m.events { + if e.Phase == phase { + found = append(found, e) + } + } + require.Len(t, found, 1, "want exactly one %s event", phase) + return found[0] +} + +// backupSnapshot reads a backup read-only, the way the previous release +// would see it after it is renamed back over the store. +type backupSnapshot struct { + version, nodes, edges int + integrity, journal string +} + +func readBackupSnapshot(t *testing.T, path string) backupSnapshot { + t.Helper() + db, err := sql.Open("sqlite", sqliteDSN(path, "mode=ro")) + require.NoError(t, err) + defer db.Close() + var snap backupSnapshot + require.NoError(t, db.QueryRow("PRAGMA user_version").Scan(&snap.version)) + require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&snap.nodes)) + require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM edges").Scan(&snap.edges)) + require.NoError(t, db.QueryRow("PRAGMA integrity_check").Scan(&snap.integrity)) + require.NoError(t, db.QueryRow("PRAGMA journal_mode").Scan(&snap.journal)) + return snap +} + +func graphCounts(t *testing.T, path string) (nodes, edges int) { + t.Helper() + withRawDB(t, path, func(db *sql.DB) { + require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodes)) + require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM edges").Scan(&edges)) + }) + return nodes, edges +} + +func requireNoPartialBackup(t *testing.T, path string) { + t.Helper() + _, partials, err := scanUpgradeBackups(path) + require.NoError(t, err) + require.Empty(t, partials, "no partial copy may outlive Open") +} + +func TestUpgradeBackupCreatedWhenSpaceAllows(t *testing.T) { + path := seedOlderStore(t, 40, 400) + nodes, edges := graphCounts(t, path) + var events migrationEvents + + s, err := Open(path, WithRebuild(), WithUpgradeBackup(), WithMigrationObserver(events.observe), withDiskAvail(fixedDiskAvail(1<<50))) + require.NoError(t, err) + require.NoError(t, s.Close()) + + backup := UpgradeBackupPath(path, 40) + created := events.only(t, MigrationBackupCreated) + require.Equal(t, backup, created.Detail) + require.Equal(t, 40, created.Version) + require.Equal(t, MigrationBackupCreated, events.phases()[0], "the backup precedes the first step") + require.Contains(t, events.phases(), MigrationStarted) + + snap := readBackupSnapshot(t, backup) + require.Equal(t, backupSnapshot{version: 40, nodes: nodes, edges: edges, integrity: "ok", journal: "delete"}, snap) + for _, companion := range []string{"-wal", "-shm"} { + require.NoFileExists(t, backup+companion, "the backup is a single self-contained file") + } + requireNoPartialBackup(t, path) + require.Equal(t, currentSchemaVersion, rawUserVersion(t, path), "the upgrade still ran") + + listed, err := ListUpgradeBackups(path) + require.NoError(t, err) + require.Len(t, listed, 1) + require.Equal(t, backup, listed[0].Path) + require.Equal(t, 40, listed[0].Version) +} + +func TestUpgradeBackupSkippedWhenSpaceCoversOnlyMigration(t *testing.T) { + path := seedOlderStore(t, 40, 400) + probe, err := probeStoreSchema(path) + require.NoError(t, err) + space, pending := measureUpgradeSpace(probe, currentSchemaVersion, planSchemaMigrationWith(40, currentSchemaVersion, schemaMigrations), fixedDiskAvail(0)) + require.True(t, pending) + available := space.estimate.Need + 1 // enough to upgrade, not to copy as well + var events migrationEvents + + s, err := Open(path, WithRebuild(), WithUpgradeBackup(), WithMigrationObserver(events.observe), withDiskAvail(fixedDiskAvail(available))) + require.NoError(t, err) + require.NoError(t, s.Close()) + + skipped := events.only(t, MigrationBackupSkipped) + require.Contains(t, skipped.Detail, strconv.FormatUint(available, 10)+" bytes available", "the log line says why") + require.Contains(t, skipped.Detail, "copy") + require.NoFileExists(t, UpgradeBackupPath(path, 40)) + requireNoPartialBackup(t, path) + require.Equal(t, currentSchemaVersion, rawUserVersion(t, path), "a skipped backup does not stop the upgrade") +} + +func TestUpgradeBackupRequiresOption(t *testing.T) { + path := seedOlderStore(t, 40, 50) + var events migrationEvents + s, err := Open(path, WithMigrationObserver(events.observe), withDiskAvail(fixedDiskAvail(1<<50))) + require.NoError(t, err) + require.NoError(t, s.Close()) + require.NoFileExists(t, UpgradeBackupPath(path, 40), "only a caller holding the store lock asks for a backup") + require.NotContains(t, events.phases(), MigrationBackupSkipped) + require.Equal(t, currentSchemaVersion, rawUserVersion(t, path)) +} + +func TestUpgradeBackupKeepsExistingSnapshot(t *testing.T) { + path := seedOlderStore(t, 40, 50) + backup := UpgradeBackupPath(path, 40) + require.NoError(t, os.WriteFile(backup, []byte("the first attempt's snapshot"), 0o600)) + require.NoError(t, os.WriteFile(backup+upgradeBackupPartialSuffix, []byte("torn"), 0o600)) + var events migrationEvents + + s, err := Open(path, WithRebuild(), WithUpgradeBackup(), WithMigrationObserver(events.observe), withDiskAvail(fixedDiskAvail(1<<50))) + require.NoError(t, err) + require.NoError(t, s.Close()) + + require.Equal(t, backup, events.only(t, MigrationBackupKept).Detail) + got, err := os.ReadFile(backup) + require.NoError(t, err) + require.Equal(t, "the first attempt's snapshot", string(got), "a retried upgrade must not replace the pre-upgrade snapshot") + requireNoPartialBackup(t, path) +} + +func TestUpgradeBackupFoldsLeftoverWAL(t *testing.T) { + path := seedOlderStore(t, 43, 100) + // Stamp the old version through a connection that never checkpoints, the + // way a crashed process leaves it: the version and the newest rows exist + // only in the -wal file. + db, err := openSQLiteStoreDB(sqliteWriterDSN(path)) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO nodes (id, kind, name, file_path, repo_prefix) SELECT id || '#wal', kind, name, file_path, repo_prefix FROM nodes`) + require.NoError(t, err) + require.NoError(t, setUserVersion(db, 40)) + require.NoError(t, db.Close()) + info, err := os.Stat(path + "-wal") + require.NoError(t, err) + require.Positive(t, info.Size(), "the fixture must leave committed frames in the WAL") + probe, err := probeStoreSchema(path) + require.NoError(t, err) + require.Equal(t, 40, probe.stored) + var events migrationEvents + + s, err := Open(path, WithRebuild(), WithUpgradeBackup(), WithMigrationObserver(events.observe), withDiskAvail(fixedDiskAvail(1<<50))) + require.NoError(t, err) + require.NoError(t, s.Close()) + + backup := UpgradeBackupPath(path, 40) + require.Equal(t, backup, events.only(t, MigrationBackupCreated).Detail) + snap := readBackupSnapshot(t, backup) + require.Equal(t, 40, snap.version, "the version held only in the WAL is in the backup") + require.Equal(t, 200, snap.nodes, "rows held only in the WAL are in the backup") + require.Equal(t, "ok", snap.integrity) + require.Equal(t, "delete", snap.journal) + require.NoFileExists(t, backup+"-wal") +} + +func TestPruneUpgradeBackupsRemovesOnlyExpired(t *testing.T) { + dir := t.TempDir() + store := filepath.Join(dir, "store.sqlite") + now := time.Now() + write := func(name string, age time.Duration) string { + p := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(p, []byte(name), 0o600)) + require.NoError(t, os.Chtimes(p, now.Add(-age), now.Add(-age))) + return p + } + expired := write("store.sqlite.v21.backup", 15*24*time.Hour) + recent := write("store.sqlite.v40.backup", 5*24*time.Hour) + partial := write("store.sqlite.v40.backup.partial", time.Minute) + partialWAL := write("store.sqlite.v40.backup.partial-wal", time.Minute) + orphanWAL := write("store.sqlite.v39.backup.partial-wal", time.Minute) + unrelated := []string{ + write("store.sqlite.bak", 30*24*time.Hour), + write("other.sqlite.v21.backup", 30*24*time.Hour), + write("store.sqlite.vX.backup", 30*24*time.Hour), + write("store.sqlite", 30*24*time.Hour), + } + + removed, err := PruneUpgradeBackups(store, UpgradeBackupRetention, now) + require.NoError(t, err) + require.ElementsMatch(t, []string{expired, partial, filepath.Join(dir, "store.sqlite.v39.backup.partial")}, removed) + for _, gone := range []string{expired, partial, partialWAL, orphanWAL} { + require.NoFileExists(t, gone) + } + require.FileExists(t, recent, "a backup inside the retention window is kept") + for _, keep := range unrelated { + require.FileExists(t, keep) + } +} + +func TestRemoveUpgradeBackupsLeavesPartialCopies(t *testing.T) { + dir := t.TempDir() + store := filepath.Join(dir, "store.sqlite") + for _, name := range []string{"store.sqlite.v21.backup", "store.sqlite.v40.backup", "store.sqlite.v43.backup.partial"} { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(name), 0o600)) + } + + removed, err := RemoveUpgradeBackups(store) + require.NoError(t, err) + require.Len(t, removed, 2) + require.Equal(t, []int{21, 40}, []int{removed[0].Version, removed[1].Version}) + require.NoFileExists(t, filepath.Join(dir, "store.sqlite.v21.backup")) + require.NoFileExists(t, filepath.Join(dir, "store.sqlite.v40.backup")) + require.FileExists(t, filepath.Join(dir, "store.sqlite.v43.backup.partial"), "a partial may be in progress under another daemon's lock") + + removed, err = RemoveUpgradeBackups(store) + require.NoError(t, err) + require.Empty(t, removed) +} diff --git a/internal/graph/store_sqlite/upgrade_fixture_test.go b/internal/graph/store_sqlite/upgrade_fixture_test.go new file mode 100644 index 000000000..8fef0e982 --- /dev/null +++ b/internal/graph/store_sqlite/upgrade_fixture_test.go @@ -0,0 +1,306 @@ +package store_sqlite + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Upgrade fixtures. +// +// testdata/upgrade/schema-v.sql holds the exact DDL (sqlite_master) of a +// store a released or current build wrote at user_version N. The upgrade +// tests replay it into a fresh file and fill nodes and edges with synthetic +// rows, which gives a store with the old release's real shape at gortex +// scale without committing anyone's indexed code. +// +// To add a fixture, index any repository with the build in question, stop +// its daemon, and run +// +// GORTEX_TEST_UPGRADE_CAPTURE_FROM= \ +// GORTEX_TEST_UPGRADE_CAPTURE_TO=testdata/upgrade/schema-v.sql \ +// GORTEX_TEST_UPGRADE_CAPTURE_SOURCE="" \ +// go test -run '^TestHelperCaptureUpgradeSchema$' ./internal/graph/store_sqlite/ +// +// Every fixture older than currentSchemaVersion is upgraded by the kill +// tests automatically; the one at the current version becomes an upgrade +// fixture as soon as the next schema step is appended. + +const upgradeFixtureDir = "testdata/upgrade" + +// upgradeFixtureScale is how many synthetic rows a fixture store gets. The +// default is about a sixth of the graph the daemon builds for this repository +// today: deep enough for every index rebuild to do real work, small enough +// for the crash matrix to fit a CI budget. Race and -short runs, the Linux CI +// leg among them, build a tenth of it; every kill point is a seam, so a +// smaller store moves no kill off its boundary. GORTEX_TEST_UPGRADE_STORE +// runs the same matrix on a full-size real store. +type upgradeFixtureScale struct { + nodes, edges int +} + +var ( + upgradeFixtureGortexScale = upgradeFixtureScale{nodes: 31_000, edges: 206_000} + upgradeFixtureSmallScale = upgradeFixtureScale{nodes: 600, edges: 4_000} +) + +type upgradeFixture struct { + version int + file string + statements []string +} + +var upgradeFixtureVersionLine = regexp.MustCompile(`(?m)^-- user_version: (\d+)$`) + +// loadUpgradeSchemaFixtures reads every committed schema fixture, oldest +// first. +func loadUpgradeSchemaFixtures(t testing.TB) []upgradeFixture { + t.Helper() + files, err := filepath.Glob(filepath.Join(upgradeFixtureDir, "schema-v*.sql")) + require.NoError(t, err) + require.NotEmpty(t, files, "no schema fixtures under %s", upgradeFixtureDir) + fixtures := make([]upgradeFixture, 0, len(files)) + for _, file := range files { + raw, err := os.ReadFile(file) + require.NoError(t, err) + text := strings.ReplaceAll(string(raw), "\r\n", "\n") + m := upgradeFixtureVersionLine.FindStringSubmatch(text) + require.NotNil(t, m, "%s has no user_version header", file) + version, err := strconv.Atoi(m[1]) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("schema-v%d.sql", version), filepath.Base(file), "file name and header disagree") + fixtures = append(fixtures, upgradeFixture{version: version, file: file, statements: splitUpgradeFixtureSQL(text)}) + } + slices.SortFunc(fixtures, func(a, b upgradeFixture) int { return a.version - b.version }) + return fixtures +} + +// splitUpgradeFixtureSQL splits a fixture into statements. Statements end at +// a line holding only ";", which DDL text from sqlite_master never contains +// (trigger bodies carry their own semicolons mid-line). +func splitUpgradeFixtureSQL(text string) []string { + var statements []string + var current []string + for _, line := range strings.Split(text, "\n") { + if strings.TrimSpace(line) == ";" { + if stmt := strings.TrimSpace(strings.Join(current, "\n")); stmt != "" { + statements = append(statements, stmt) + } + current = current[:0] + continue + } + if len(current) == 0 && strings.HasPrefix(line, "--") { + continue // header comments between statements + } + current = append(current, line) + } + return statements +} + +// TestHelperCaptureUpgradeSchema writes the schema of an existing store as a +// fixture. It is a tool rather than a test and skips unless pointed at a +// store; see the comment at the top of this file. +func TestHelperCaptureUpgradeSchema(t *testing.T) { + from, to := os.Getenv("GORTEX_TEST_UPGRADE_CAPTURE_FROM"), os.Getenv("GORTEX_TEST_UPGRADE_CAPTURE_TO") + if from == "" || to == "" { + t.Skip("set GORTEX_TEST_UPGRADE_CAPTURE_FROM and GORTEX_TEST_UPGRADE_CAPTURE_TO to capture a schema fixture") + } + source := os.Getenv("GORTEX_TEST_UPGRADE_CAPTURE_SOURCE") + require.NotEmpty(t, source, "name the build that wrote the store in GORTEX_TEST_UPGRADE_CAPTURE_SOURCE") + // Read-only, through plain database/sql: never through Open, which would + // reconcile the very schema being captured. + db, err := sql.Open("sqlite", sqliteDSN(from, "mode=ro")) + require.NoError(t, err) + defer db.Close() + version, err := readUserVersion(db) + require.NoError(t, err) + require.Positive(t, version) + + shadow := map[string]bool{} + rows, err := db.Query(`SELECT name FROM pragma_table_list WHERE schema = 'main' AND type = 'shadow'`) + require.NoError(t, err) + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + shadow[name] = true + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + + // Tables before the indexes, views and triggers that depend on them; each + // group in creation order. + rows, err = db.Query(`SELECT type, name, sql FROM sqlite_master + WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite\_%' ESCAPE '\' + ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'view' THEN 2 ELSE 3 END, rowid`) + require.NoError(t, err) + var b strings.Builder + fmt.Fprintf(&b, "-- Schema of a gortex graph store, captured by TestHelperCaptureUpgradeSchema\n") + fmt.Fprintf(&b, "-- from a store written by %s.\n", source) + fmt.Fprintf(&b, "-- user_version: %d\n", version) + objects := map[string]int{} + for rows.Next() { + var kind, name, ddl string + require.NoError(t, rows.Scan(&kind, &name, &ddl)) + if shadow[name] { + continue // recreated by its virtual table + } + objects[kind]++ + fmt.Fprintf(&b, "\n%s\n;\n", strings.TrimSpace(strings.ReplaceAll(ddl, "\r\n", "\n"))) + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + require.NoError(t, os.WriteFile(to, []byte(b.String()), 0o644)) + t.Logf("captured v%d: %v", version, objects) + for _, table := range []string{"nodes", "edges"} { + var n int + require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM "+table).Scan(&n)) + t.Logf("source %s rows: %d", table, n) + } +} + +// upgradeFixtureNodeID is the SQL id of synthetic node number x. +func upgradeFixtureNodeID(x string) string { + return fmt.Sprintf(`'repo' || ((%[1]s) %% 4) || '/pkg' || ((%[1]s) %% 300) || '/f' || ((%[1]s) %% 2700) || '.go::S' || (%[1]s)`, x) +} + +// upgradeFixtureViewGen spreads about one row in ten over non-base view +// generations, the way worktree views populate a real store. +const upgradeFixtureViewGen = `CASE WHEN i % 10 = 9 THEN 1 + i % 3 ELSE 0 END` + +// upgradeFixtureColumns gives the SQL expression (over the row number i) for +// every synthetic column the loader fills. A column absent here is left to +// its default; a NOT NULL column without one fails the load loudly, so a +// fixture with a new required column cannot silently lose rows. +func upgradeFixtureColumns(table string, scale upgradeFixtureScale) map[string]string { + n := strconv.Itoa(scale.nodes) + filePath := `'pkg' || (i % 300) || '/f' || (i % 2700) || '.go'` + switch table { + case "nodes": + return map[string]string{ + "id": upgradeFixtureNodeID("i"), + "view_gen": upgradeFixtureViewGen, + "kind": `CASE i % 5 WHEN 0 THEN 'function' WHEN 1 THEN 'method' WHEN 2 THEN 'type' WHEN 3 THEN 'variable' ELSE 'field' END`, + "name": `'S' || i`, + "qual_name": `'pkg' || (i % 300) || '.S' || i`, + "file_path": filePath, + "start_line": `i % 400 + 1`, + "end_line": `i % 400 + 12`, + "language": `'go'`, + "repo_prefix": `'repo' || (i % 4)`, + "signature": `'func S' || i || '(ctx context.Context) error'`, + "visibility": `CASE WHEN i % 2 = 0 THEN 'public' ELSE 'private' END`, + "is_exported": `CASE WHEN i % 2 = 0 THEN 1 ELSE 0 END`, + "updated_at": `1761000000 + i`, + } + case "edges": + return map[string]string{ + "from_id": upgradeFixtureNodeID("i % " + n), + // (from, kind, to) stays unique: rows sharing a source and a kind + // differ in i / n, which shifts the target. + "to_id": upgradeFixtureNodeID("(31 * (i % " + n + ") + i / " + n + " + 1) % " + n), + "kind": `CASE (i / ` + n + `) % 6 WHEN 0 THEN 'calls' WHEN 1 THEN 'references' WHEN 2 THEN 'defines' WHEN 3 THEN 'imports' WHEN 4 THEN 'member_of' ELSE 'implements' END`, + "file_path": filePath, + "line": `i % 400 + 1`, + "origin": `'ast'`, + "view_gen": upgradeFixtureViewGen, + } + } + return nil +} + +// buildUpgradeFixtureStore replays fixture into dir/store.sqlite, fills it at +// scale, stamps the fixture's version and leaves it checkpointed in WAL mode, +// as a cleanly stopped daemon of that release would. +func buildUpgradeFixtureStore(t testing.TB, fixture upgradeFixture, scale upgradeFixtureScale, dir string) string { + t.Helper() + path := filepath.Join(dir, "store.sqlite") + db, err := sql.Open("sqlite", sqliteDSN(path, "_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")) + require.NoError(t, err) + defer db.Close() + db.SetMaxOpenConns(1) + for _, stmt := range fixture.statements { + _, err := db.Exec(stmt) + require.NoError(t, err, "%s: replay %q", fixture.file, stmt) + } + for _, fill := range []struct { + table string + rows int + }{{"nodes", scale.nodes}, {"edges", scale.edges}} { + insert := upgradeFixtureInsert(t, db, fixture, fill.table, upgradeFixtureColumns(fill.table, scale)) + _, err := db.Exec(insert, fill.rows-1) + require.NoError(t, err, "%s: fill %s", fixture.file, fill.table) + } + _, err = db.Exec(fmt.Sprintf("PRAGMA user_version = %d", fixture.version)) + require.NoError(t, err) + _, err = db.Exec("PRAGMA wal_checkpoint(TRUNCATE)") + require.NoError(t, err) + return path +} + +// upgradeFixtureInsert builds the INSERT that fills table with synthetic +// rows, from the columns the replayed schema actually has. +func upgradeFixtureInsert(t testing.TB, db *sql.DB, fixture upgradeFixture, table string, rules map[string]string) string { + t.Helper() + rows, err := db.Query(`SELECT name, "notnull", dflt_value IS NOT NULL, pk, hidden FROM pragma_table_xinfo(?)`, table) + require.NoError(t, err) + defer rows.Close() + var columns, values []string + for rows.Next() { + var name string + var notNull, hasDefault bool + var pk, hidden int + require.NoError(t, rows.Scan(&name, ¬Null, &hasDefault, &pk, &hidden)) + if hidden != 0 { + continue // generated or hidden: SQLite fills it + } + if expr, ok := rules[name]; ok { + columns = append(columns, name) + values = append(values, expr) + continue + } + integerKey := pk == 1 && name == "id" && table == "edges" + require.False(t, notNull && !hasDefault && !integerKey, + "%s: %s.%s is NOT NULL without a default; give the fixture loader a value for it", fixture.file, table, name) + } + require.NoError(t, rows.Err()) + require.NotEmpty(t, columns, "%s: no %s table to fill", fixture.file, table) + return fmt.Sprintf(`WITH RECURSIVE seq(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM seq WHERE i < ?) + INSERT INTO %s (%s) SELECT %s FROM seq`, table, strings.Join(columns, ", "), strings.Join(values, ", ")) +} + +func TestUpgradeFixturesReplayTheirSchema(t *testing.T) { + for _, fixture := range loadUpgradeSchemaFixtures(t) { + t.Run(fmt.Sprintf("v%d", fixture.version), func(t *testing.T) { + require.LessOrEqual(t, fixture.version, currentSchemaVersion, "a fixture newer than this build cannot be upgraded") + path := buildUpgradeFixtureStore(t, fixture, upgradeFixtureSmallScale, t.TempDir()) + db, err := sql.Open("sqlite", sqliteDSN(path, "mode=ro")) + require.NoError(t, err) + defer db.Close() + version, err := readUserVersion(db) + require.NoError(t, err) + require.Equal(t, fixture.version, version) + var integrity string + require.NoError(t, db.QueryRow("PRAGMA integrity_check").Scan(&integrity)) + require.Equal(t, "ok", integrity) + var nodes, edges, viewNodes int + require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodes)) + require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM edges").Scan(&edges)) + require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM nodes WHERE view_gen <> 0").Scan(&viewNodes)) + require.Equal(t, upgradeFixtureSmallScale.nodes, nodes) + require.Equal(t, upgradeFixtureSmallScale.edges, edges) + require.Equal(t, upgradeFixtureSmallScale.nodes/10, viewNodes, "about one row in ten lives in a view generation") + var objects int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite\_%' ESCAPE '\' AND sql IS NOT NULL + AND name NOT IN (SELECT name FROM pragma_table_list WHERE type = 'shadow')`).Scan(&objects)) + require.Len(t, fixture.statements, objects, "the replayed store carries exactly the captured objects") + }) + } +} diff --git a/internal/graph/store_sqlite/upgrade_kill_test.go b/internal/graph/store_sqlite/upgrade_kill_test.go new file mode 100644 index 000000000..645ef4138 --- /dev/null +++ b/internal/graph/store_sqlite/upgrade_kill_test.go @@ -0,0 +1,350 @@ +package store_sqlite + +import ( + "bufio" + "crypto/sha256" + "database/sql" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// Crash tests for the schema transition. +// +// A helper process (this test binary re-run on TestHelperUpgradeKillChild) +// opens a copy of an old store the way the daemon does and stops at one +// upgrade point; the parent SIGKILLs it there, then reopens the store and +// requires the result to equal an uninterrupted upgrade of the same store. + +func TestUpgradeKillPointSeamsFireInOrder(t *testing.T) { + path := seedOlderStore(t, 40, 50) + var points []string + s, err := Open(path, WithRebuild(), WithUpgradeBackup(), withDiskAvail(fixedDiskAvail(1<<50)), + withUpgradeHook(func(point upgradePoint, version int) { + points = append(points, fmt.Sprintf("%s@%d", point, version)) + })) + require.NoError(t, err) + require.NoError(t, s.Close()) + + want := []string{fmt.Sprintf("%s@40", upgradeBackupCopied)} + for v := 41; v <= currentSchemaVersion; v++ { + want = append(want, fmt.Sprintf("%s@%d", upgradeInStep, v)) + } + want = append(want, + fmt.Sprintf("%s@%d", upgradeIndexRecreation, currentSchemaVersion), + fmt.Sprintf("%s@%d", upgradeBeforeStamp, currentSchemaVersion)) + require.Equal(t, want, points) + + // A current store crosses no boundary at all. + points = nil + s, err = Open(path, withUpgradeHook(func(point upgradePoint, version int) { + points = append(points, fmt.Sprintf("%s@%d", point, version)) + })) + require.NoError(t, err) + require.NoError(t, s.Close()) + require.Empty(t, points) +} + +const ( + upgradeKillStoreEnv = "GORTEX_TEST_UPGRADE_KILL_STORE" + upgradeKillPointEnv = "GORTEX_TEST_UPGRADE_KILL_POINT" + upgradeKillVersionEnv = "GORTEX_TEST_UPGRADE_KILL_VERSION" + // upgradeExternalStoreEnv runs the same matrix on a copy of a real store + // (for example one an older release wrote for a large corpus). Local + // validation only; CI uses the committed fixtures. + upgradeExternalStoreEnv = "GORTEX_TEST_UPGRADE_STORE" + upgradeKillReadyLine = "UPGRADE-KILL-POINT" +) + +// upgradeDaemonOptions are the options the daemon opens its store with, +// minus the volume probe: the crash tests are about recovery, not space. +func upgradeDaemonOptions(extra ...Option) []Option { + return append([]Option{WithRebuild(), WithUpgradeBackup(), withDiskAvail(fixedDiskAvail(1 << 50))}, extra...) +} + +// TestHelperUpgradeKillChild is the helper process: it upgrades the store +// named in the environment and parks at the requested point until killed. +func TestHelperUpgradeKillChild(t *testing.T) { + store := os.Getenv(upgradeKillStoreEnv) + if store == "" { + t.Skip("helper process for TestUpgradeKillPointsRecoverEveryFixture") + } + point := upgradePoint(os.Getenv(upgradeKillPointEnv)) + version, err := strconv.Atoi(os.Getenv(upgradeKillVersionEnv)) + require.NoError(t, err) + s, err := Open(store, upgradeDaemonOptions(withUpgradeHook(func(at upgradePoint, v int) { + if at != point || v != version { + return + } + fmt.Printf("%s %s %d\n", upgradeKillReadyLine, at, v) + _ = os.Stdout.Sync() + for { + time.Sleep(time.Hour) // the parent kills the process here + } + }))...) + if s != nil { + _ = s.Close() + } + t.Fatalf("upgrade finished without reaching %s@%d (err=%v)", point, version, err) +} + +// upgradeKillPoint is one place the matrix kills the helper. +type upgradeKillPoint struct { + point upgradePoint + version int + label string +} + +// upgradeKillPoints lists where an upgrade from stored is killed: after +// the backup copy, inside the first, middle and last pending step, +// during index recreation and just before the stamp. +func upgradeKillPoints(stored int) []upgradeKillPoint { + pending := pendingBetween(stored, currentSchemaVersion, schemaMigrations) + if len(pending) == 0 { + return nil + } + points := []upgradeKillPoint{{upgradeBackupCopied, stored, "backup_copied"}} + seen := map[int]bool{} + for _, pick := range []struct { + label string + index int + }{{"first_step", 0}, {"middle_step", len(pending) / 2}, {"last_step", len(pending) - 1}} { + v := pending[pick.index].version + if seen[v] { + continue + } + seen[v] = true + points = append(points, upgradeKillPoint{upgradeInStep, v, fmt.Sprintf("%s_v%d", pick.label, v)}) + } + return append(points, + upgradeKillPoint{upgradeIndexRecreation, currentSchemaVersion, "index_recreation"}, + upgradeKillPoint{upgradeBeforeStamp, currentSchemaVersion, "before_stamp"}) +} + +// upgradeOutcome is what an upgraded store must agree on with an +// uninterrupted upgrade of the same starting store. +type upgradeOutcome struct { + version int + integrity string + nodes int + edges int + digest string +} + +func readUpgradeOutcome(t testing.TB, path string) upgradeOutcome { + t.Helper() + db, err := openSQLiteStoreDB(sqliteDSN(path, "mode=ro")) + require.NoError(t, err) + defer db.Close() + db.SetMaxOpenConns(1) + var out upgradeOutcome + require.NoError(t, db.QueryRow("PRAGMA user_version").Scan(&out.version)) + require.NoError(t, db.QueryRow("PRAGMA integrity_check").Scan(&out.integrity)) + require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&out.nodes)) + require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM edges").Scan(&out.edges)) + h := sha256.New() + for _, q := range []string{ + `SELECT id, view_gen, kind FROM nodes ORDER BY id, view_gen`, + `SELECT from_id, to_id, kind, view_gen, file_path, line FROM edges ORDER BY from_id, to_id, kind, view_gen, file_path, line`, + } { + digestRows(t, db, q, h) + } + out.digest = fmt.Sprintf("%x", h.Sum(nil)) + return out +} + +func digestRows(t testing.TB, db *sql.DB, query string, w io.Writer) { + t.Helper() + rows, err := db.Query(query) + require.NoError(t, err) + defer rows.Close() + cols, err := rows.Columns() + require.NoError(t, err) + values := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range values { + ptrs[i] = &values[i] + } + for rows.Next() { + require.NoError(t, rows.Scan(ptrs...)) + fmt.Fprintln(w, values...) + } + require.NoError(t, rows.Err()) +} + +// copyUpgradeStore copies a store with its WAL into dir, as a stopped +// daemon's disk would hold it. +func copyUpgradeStore(t testing.TB, src, dir string) string { + t.Helper() + dst := filepath.Join(dir, "store.sqlite") + for _, suffix := range []string{"", "-wal"} { + in, err := os.Open(src + suffix) + if suffix != "" && os.IsNotExist(err) { + continue + } + require.NoError(t, err) + out, err := os.Create(dst + suffix) + require.NoError(t, err) + _, err = io.Copy(out, in) + require.NoError(t, err) + require.NoError(t, out.Close()) + require.NoError(t, in.Close()) + } + return dst +} + +// upgradeKillWait bounds how long the helper may take to reach a point. +func upgradeKillWait() time.Duration { + if raceDetectorOn { + return 10 * time.Minute + } + return 3 * time.Minute +} + +// killUpgradeAt runs the helper on store until it parks at kp, then SIGKILLs +// it (TerminateProcess on Windows). +func killUpgradeAt(t *testing.T, store string, kp upgradeKillPoint) { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestHelperUpgradeKillChild$") + cmd.Env = append(os.Environ(), + upgradeKillStoreEnv+"="+store, + upgradeKillPointEnv+"="+string(kp.point), + upgradeKillVersionEnv+"="+strconv.Itoa(kp.version)) + stdout, err := cmd.StdoutPipe() + require.NoError(t, err) + var stderr strings.Builder + cmd.Stderr = &stderr + require.NoError(t, cmd.Start()) + exited := false + defer func() { + if !exited { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + }() + ready := make(chan string, 1) + transcript := make(chan string, 1) + go func() { + var seen strings.Builder + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + seen.WriteString(line + "\n") + if strings.HasPrefix(line, upgradeKillReadyLine+" ") { + ready <- line + } + } + transcript <- seen.String() + close(ready) + }() + select { + case line, ok := <-ready: + if !ok { + _ = cmd.Wait() + exited = true + t.Fatalf("helper exited before %s:\n%s\nstderr:\n%s", kp.label, <-transcript, stderr.String()) + } + require.Equal(t, fmt.Sprintf("%s %s %d", upgradeKillReadyLine, kp.point, kp.version), line) + case <-time.After(upgradeKillWait()): + t.Fatalf("helper did not reach %s within %s", kp.label, upgradeKillWait()) + } + require.NoError(t, cmd.Process.Kill()) + _ = cmd.Wait() // a killed process reports its signal, not success + exited = true +} + +// upgradeKillSource is one starting store the matrix upgrades. +type upgradeKillSource struct { + name string + path string + version int +} + +// upgradeSources builds a store from every committed fixture, plus a copy of +// the real store named by upgradeExternalStoreEnv when it is set. +func upgradeSources(t *testing.T) []upgradeKillSource { + t.Helper() + scale := upgradeFixtureGortexScale + if testing.Short() || raceDetectorOn { + scale = upgradeFixtureScale{nodes: scale.nodes / 10, edges: scale.edges / 10} + } + var sources []upgradeKillSource + for _, fixture := range loadUpgradeSchemaFixtures(t) { + start := time.Now() + path := buildUpgradeFixtureStore(t, fixture, scale, t.TempDir()) + t.Logf("fixture v%d: %d nodes, %d edges built in %s", fixture.version, scale.nodes, scale.edges, time.Since(start).Round(time.Millisecond)) + sources = append(sources, upgradeKillSource{name: fmt.Sprintf("fixture_v%d", fixture.version), path: path, version: fixture.version}) + } + if external := os.Getenv(upgradeExternalStoreEnv); external != "" { + path := copyUpgradeStore(t, external, t.TempDir()) + probe, err := probeStoreSchema(path) + require.NoError(t, err) + sources = append(sources, upgradeKillSource{name: fmt.Sprintf("external_v%d", probe.stored), path: path, version: probe.stored}) + } + return sources +} + +func TestUpgradeKillPointsRecoverEveryFixture(t *testing.T) { + for _, source := range upgradeSources(t) { + t.Run(source.name, func(t *testing.T) { + before := readUpgradeOutcome(t, source.path) + points := upgradeKillPoints(source.version) + if len(points) == 0 { + // The current schema: nothing to interrupt until the next step + // is appended, but opening it must stay a no-op. + store := copyUpgradeStore(t, source.path, t.TempDir()) + var events migrationEvents + s, err := Open(store, upgradeDaemonOptions(WithMigrationObserver(events.observe))...) + require.NoError(t, err) + require.NoError(t, s.Close()) + require.Empty(t, events.phases()) + require.Equal(t, before, readUpgradeOutcome(t, store)) + return + } + + baselineStore := copyUpgradeStore(t, source.path, t.TempDir()) + start := time.Now() + s, err := Open(baselineStore, upgradeDaemonOptions()...) + require.NoError(t, err) + require.NoError(t, s.Close()) + t.Logf("uninterrupted upgrade v%d -> v%d: %s", source.version, currentSchemaVersion, time.Since(start).Round(time.Millisecond)) + want := readUpgradeOutcome(t, baselineStore) + require.Equal(t, currentSchemaVersion, want.version) + require.Equal(t, "ok", want.integrity) + + for _, kp := range points { + t.Run(kp.label, func(t *testing.T) { + store := copyUpgradeStore(t, source.path, t.TempDir()) + killUpgradeAt(t, store, kp) + + probe, err := probeStoreSchema(store) + require.NoError(t, err) + require.Equal(t, source.version, probe.stored, "a kill before the stamp leaves the old version") + + s, err := Open(store, upgradeDaemonOptions()...) + require.NoError(t, err, "the restart completes the upgrade") + require.NoError(t, s.Close()) + require.Equal(t, want, readUpgradeOutcome(t, store), "the restarted upgrade equals an uninterrupted one") + + // The backup a killed attempt finalized is only kept by the + // restart; one killed before its rename is taken again, so + // that is the point to prove the snapshot complete. + backup := UpgradeBackupPath(store, source.version) + require.FileExists(t, backup) + if kp.point == upgradeBackupCopied { + require.Equal(t, before, readUpgradeOutcome(t, backup), "the backup is the store as it was before the first attempt") + } + requireNoPartialBackup(t, store) + }) + } + }) + } +} diff --git a/internal/graph/store_sqlite/upgrade_space.go b/internal/graph/store_sqlite/upgrade_space.go new file mode 100644 index 000000000..d18d02695 --- /dev/null +++ b/internal/graph/store_sqlite/upgrade_space.go @@ -0,0 +1,233 @@ +package store_sqlite + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + + "github.com/zzet/gortex/internal/platform" +) + +// Free-space precheck for an in-place schema upgrade. +// +// Open runs every pending in-place step in one transaction with automatic +// checkpoints off, then rebuilds the core and sidecar indexes, so the -wal +// file holds every page the upgrade touched until the first checkpoint after +// Open. Running out of disk partway through leaves the old version in place +// (the stamp is the last write), but it also leaves a WAL the size of +// whatever fit and a daemon that fails the same way on every restart. The +// precheck refuses before the writer connection exists instead, so a store +// the disk cannot upgrade is never touched. +// +// The estimate is deliberately version-agnostic: it reads only the store's +// page counts and whether every pending step is an explicitly additive one, +// so new registry entries need no sizing data of their own. + +const ( + // upgradeRewritePercent is the share of live pages a transition is + // assumed to write when any pending step may reshape graph data (a + // re-scoped hot index, a backfill). Measured worst case, WAL peak plus + // main-file growth: 0.56x live upgrading the v21 fixture and 0.46x + // upgrading a 1.5 GB v21 store a release wrote, against about 1.0x for + // this share. A step that rebuilds whole graph tables (edges and nodes + // with their indexes are about 80% of a real store) writes more than + // that and must raise this share. + upgradeRewritePercent = 50 + // upgradeAdditiveStepPercent is the share of live pages each explicitly + // additive step (one that preserves analysis: a single new or reshaped + // index) is assumed to write, capped at upgradeRewritePercent in total. + // The three such steps measured wrote 0.016x live each; the largest + // secondary index of a real store is 7.2% of it, which this share + // still covers. + upgradeAdditiveStepPercent = 10 + // upgradeSpaceHeadroom covers what the estimate does not model: the + // tables and columns schemaSQL adds, the FTS backfills, the analysis + // invalidation, and the -shm file. It equals journal_size_limit, the + // WAL size a store keeps between checkpoints anyway. + upgradeSpaceHeadroom = 64 << 20 + + // walFileHeaderBytes is the fixed header at the start of a -wal file; + // each frame after it carries walFrameHeaderBytes of its own. + walFileHeaderBytes = 32 +) + +// ErrInsufficientUpgradeSpace means Open refused a schema upgrade because +// the volume holding the store has less free space than the upgrade needs. +// The store is left exactly as it was. +var ErrInsufficientUpgradeSpace = errors.New("not enough free disk space to upgrade the sqlite store schema") + +// InsufficientUpgradeSpaceError names the store, the transition, and both +// byte counts. Callers can match ErrInsufficientUpgradeSpace with errors.Is. +type InsufficientUpgradeSpaceError struct { + Path string + From int + To int + Need uint64 + Available uint64 +} + +func (e *InsufficientUpgradeSpaceError) Error() string { + return fmt.Sprintf("%v: upgrading %s from v%d to v%d needs %d bytes (%s) free, %d bytes (%s) available; "+ + "the store was not modified — free disk space on that volume or move the store to a larger one, then start again", + ErrInsufficientUpgradeSpace, e.Path, e.From, e.To, e.Need, formatMiB(e.Need), e.Available, formatMiB(e.Available)) +} + +func (e *InsufficientUpgradeSpaceError) Unwrap() error { return ErrInsufficientUpgradeSpace } + +func formatMiB(n uint64) string { + return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) +} + +// storeSpaceStats is the on-disk shape of a store as the read-only probe sees +// it, WAL contents included. +type storeSpaceStats struct { + pageSize uint64 + pageCount uint64 + freelistPages uint64 + mainBytes uint64 // main database file size + walBytes uint64 // -wal file size; 0 when absent +} + +func (s storeSpaceStats) livePages() uint64 { + if s.freelistPages >= s.pageCount { + return 0 + } + return s.pageCount - s.freelistPages +} + +// readStoreSpaceStats reads page counts through the probe's read-only URI, so +// nothing about the store changes. +func readStoreSpaceStats(probe storeProbe) (storeSpaceStats, error) { + var stats storeSpaceStats + db, err := openSQLiteStoreDB(probe.uri) + if err != nil { + return stats, err + } + db.SetMaxOpenConns(1) + defer db.Close() + for _, p := range []struct { + pragma string + out *uint64 + }{ + {"page_size", &stats.pageSize}, + {"page_count", &stats.pageCount}, + {"freelist_count", &stats.freelistPages}, + } { + if err := db.QueryRow("PRAGMA " + p.pragma).Scan(p.out); err != nil { + return stats, fmt.Errorf("PRAGMA %s: %w", p.pragma, err) + } + } + info, err := os.Stat(probe.filename) + if err != nil { + return stats, err + } + stats.mainBytes = uint64(info.Size()) + if info, err := os.Stat(probe.filename + "-wal"); err == nil { + stats.walBytes = uint64(info.Size()) + } else if !os.IsNotExist(err) { + return stats, err + } + return stats, nil +} + +// upgradeSpaceEstimate is how many bytes an in-place upgrade may claim on the +// store's volume before its first checkpoint completes. +type upgradeSpaceEstimate struct { + // WALPeak is the -wal file the transition can grow: one frame per page + // it writes, since automatic checkpoints are off during Open. + WALPeak uint64 + // Growth is how far the main file may extend once those frames are + // checkpointed, after the free list has absorbed what it can. + Growth uint64 + // Headroom is the fixed allowance for everything the estimate does not + // model. + Headroom uint64 + // Need is the sum, and the figure the precheck compares with the free + // space on the volume. + Need uint64 +} + +// estimateUpgradeSpace sizes a pending transition from the store's live +// pages. An additive-only transition (every step preserves analysis) writes +// a fixed share of the live pages per step; anything else is assumed to +// write upgradeRewritePercent of them. +func estimateUpgradeSpace(stats storeSpaceStats, pending []schemaMigration) upgradeSpaceEstimate { + percent := uint64(upgradeRewritePercent) + if additiveOnly(pending) { + percent = min(uint64(len(pending))*upgradeAdditiveStepPercent, upgradeRewritePercent) + } + written := (stats.livePages()*percent + 99) / 100 + est := upgradeSpaceEstimate{Headroom: upgradeSpaceHeadroom} + if written > 0 { + est.WALPeak = walFileHeaderBytes + written*(stats.pageSize+walFrameHeaderBytes) + } + if free := stats.freelistPages; written > free { + est.Growth = (written - free) * stats.pageSize + } + est.Need = est.WALPeak + est.Growth + est.Headroom + return est +} + +func additiveOnly(pending []schemaMigration) bool { + if len(pending) == 0 { + return false + } + for _, step := range pending { + if step.inPlace == nil || step.rebuild || !step.preserveAnalysis { + return false + } + } + return true +} + +// upgradeSpace is the free-space picture of one pending in-place upgrade. +type upgradeSpace struct { + path string + from, to int + stats storeSpaceStats + estimate upgradeSpaceEstimate + available uint64 + // measured is false when the volume's free space could not be read; the + // upgrade then proceeds unchecked rather than bricking startup on a + // filesystem statfs does not understand. + measured bool +} + +// measureUpgradeSpace sizes the in-place upgrade plan would run on the probed +// store. It reports false when there is nothing to size: an in-memory or new +// store, a current or pre-versioning one, a refused version, or a rebuild +// (which deletes the file instead of growing it). +func measureUpgradeSpace(probe storeProbe, current int, plan schemaPlan, diskAvail func(string) (uint64, error)) (upgradeSpace, bool) { + if !probe.exists || probe.filename == "" || probe.stored <= 0 || plan.err != nil || plan.wipe || len(plan.inPlace) == 0 { + return upgradeSpace{}, false + } + space := upgradeSpace{path: probe.filename, from: probe.stored, to: current} + stats, err := readStoreSpaceStats(probe) + if err != nil { + log.Printf("store_sqlite: upgrade space check skipped path=%s from=v%d to=v%d reason=%q", space.path, space.from, space.to, err) + return space, true + } + space.stats = stats + space.estimate = estimateUpgradeSpace(stats, plan.inPlace) + if diskAvail == nil { + diskAvail = platform.DiskAvailBytes + } + available, err := diskAvail(filepath.Dir(probe.filename)) + if err != nil { + log.Printf("store_sqlite: upgrade space check skipped path=%s from=v%d to=v%d need=%d reason=%q", space.path, space.from, space.to, space.estimate.Need, err) + return space, true + } + space.available, space.measured = available, true + return space, true +} + +// refuseShort returns the typed refusal when the volume measurably cannot +// hold the upgrade. +func (s upgradeSpace) refuseShort() error { + if !s.measured || s.available >= s.estimate.Need { + return nil + } + return &InsufficientUpgradeSpaceError{Path: s.path, From: s.from, To: s.to, Need: s.estimate.Need, Available: s.available} +} diff --git a/internal/graph/store_sqlite/upgrade_space_test.go b/internal/graph/store_sqlite/upgrade_space_test.go new file mode 100644 index 000000000..a297338f0 --- /dev/null +++ b/internal/graph/store_sqlite/upgrade_space_test.go @@ -0,0 +1,243 @@ +package store_sqlite + +import ( + "crypto/sha256" + "database/sql" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// seedOlderStore writes a populated current-schema store and stamps it back +// to version, so the next Open runs every in-place step after it. Steps are +// idempotent, which is what makes the re-stamped store a faithful upgrade +// candidate. +func seedOlderStore(t *testing.T, version, nodes int) string { + t.Helper() + path := filepath.Join(t.TempDir(), "store.sqlite") + s, err := Open(path) + require.NoError(t, err) + batch := make([]*graph.Node, 0, nodes) + edges := make([]*graph.Edge, 0, nodes) + for i := range nodes { + batch = append(batch, &graph.Node{ + ID: fmt.Sprintf("repo/pkg/file%d.go::Func%d", i%50, i), Kind: graph.KindFunction, + Name: fmt.Sprintf("Func%d", i), FilePath: fmt.Sprintf("pkg/file%d.go", i%50), RepoPrefix: "repo", + }) + if i > 0 { + edges = append(edges, &graph.Edge{ + From: fmt.Sprintf("repo/pkg/file%d.go::Func%d", i%50, i), To: fmt.Sprintf("repo/pkg/file%d.go::Func%d", (i-1)%50, i-1), + Kind: graph.EdgeCalls, + }) + } + } + require.NoError(t, s.AddBatchChecked(batch, edges)) + require.NoError(t, s.Close()) + withRawDB(t, path, func(db *sql.DB) { require.NoError(t, setUserVersion(db, version)) }) + return path +} + +func fileDigest(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) + require.NoError(t, err) + defer f.Close() + h := sha256.New() + _, err = io.Copy(h, f) + require.NoError(t, err) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func rawUserVersion(t *testing.T, path string) int { + t.Helper() + var version int + withRawDB(t, path, func(db *sql.DB) { + var err error + version, err = readUserVersion(db) + require.NoError(t, err) + }) + return version +} + +func fixedDiskAvail(n uint64) func(string) (uint64, error) { + return func(string) (uint64, error) { return n, nil } +} + +func TestUpgradeFreeSpaceRefusalLeavesStoreByteIdentical(t *testing.T) { + path := seedOlderStore(t, 40, 400) + before := fileDigest(t, path) + const available = 12345 + + s, err := Open(path, WithRebuild(), withDiskAvail(fixedDiskAvail(available))) + require.Nil(t, s) + require.ErrorIs(t, err, ErrInsufficientUpgradeSpace) + var short *InsufficientUpgradeSpaceError + require.ErrorAs(t, err, &short) + require.Equal(t, path, short.Path) + require.Equal(t, 40, short.From) + require.Equal(t, currentSchemaVersion, short.To) + require.Equal(t, uint64(available), short.Available) + require.Greater(t, short.Need, short.Available) + + msg := err.Error() + require.Contains(t, msg, path, "the refusal names the store") + require.Contains(t, msg, strconv.FormatUint(short.Need, 10)+" bytes", "the refusal names the bytes needed") + require.Contains(t, msg, strconv.FormatUint(available, 10)+" bytes", "the refusal names the bytes available") + + require.Equal(t, before, fileDigest(t, path), "a refused upgrade must not touch the store file") + require.Equal(t, 40, rawUserVersion(t, path)) +} + +func TestUpgradeFreeSpaceEstimateScalesWithPendingSteps(t *testing.T) { + noop := func(*sql.Tx) error { return nil } + additive := func(version int) schemaMigration { + return schemaMigration{version: version, inPlace: noop, preserveAnalysis: true} + } + reshaping := schemaMigration{version: 26, inPlace: noop} + stats := storeSpaceStats{pageSize: 4096, pageCount: 10_000} + + one := estimateUpgradeSpace(stats, []schemaMigration{additive(41)}) + written := (stats.livePages()*upgradeAdditiveStepPercent + 99) / 100 + require.Equal(t, walFileHeaderBytes+written*(stats.pageSize+walFrameHeaderBytes), one.WALPeak) + require.Equal(t, written*stats.pageSize, one.Growth, "an empty free list absorbs nothing") + require.Equal(t, uint64(upgradeSpaceHeadroom), one.Headroom) + require.Equal(t, one.WALPeak+one.Growth+one.Headroom, one.Need) + + three := estimateUpgradeSpace(stats, []schemaMigration{additive(41), additive(42), additive(43)}) + full := estimateUpgradeSpace(stats, []schemaMigration{reshaping}) + require.Less(t, one.Need, three.Need, "each additive step adds its share") + require.LessOrEqual(t, three.Need, full.Need) + require.Equal(t, full, estimateUpgradeSpace(stats, []schemaMigration{additive(25), reshaping}), + "one reshaping step sizes the whole transition at the rewrite share") + many := make([]schemaMigration, 0, 10) + for v := range 10 { + many = append(many, additive(50+v)) + } + require.Equal(t, full, estimateUpgradeSpace(stats, many), "additive shares are capped at the rewrite share") + + // Free pages absorb main-file growth but not WAL frames. + freed := stats + freed.freelistPages = 1_000 + withFree := estimateUpgradeSpace(freed, []schemaMigration{reshaping}) + rewritten := (freed.livePages()*upgradeRewritePercent + 99) / 100 + require.Equal(t, walFileHeaderBytes+rewritten*(freed.pageSize+walFrameHeaderBytes), withFree.WALPeak) + require.Equal(t, (rewritten-freed.freelistPages)*freed.pageSize, withFree.Growth) + mostlyFree := stats + mostlyFree.freelistPages = 8_000 + require.Zero(t, estimateUpgradeSpace(mostlyFree, []schemaMigration{reshaping}).Growth) + + // The estimate grows with the live store. + bigger := stats + bigger.pageCount *= 2 + require.Less(t, full.Need, estimateUpgradeSpace(bigger, []schemaMigration{reshaping}).Need) + require.Equal(t, uint64(upgradeSpaceHeadroom), estimateUpgradeSpace(storeSpaceStats{pageSize: 4096}, []schemaMigration{reshaping}).Need) +} + +func TestUpgradeFreeSpaceCheckSkipsFreshMemoryAndCurrentStores(t *testing.T) { + var calls atomic.Int32 + noSpace := withDiskAvail(func(string) (uint64, error) { + calls.Add(1) + return 0, nil + }) + + fresh := filepath.Join(t.TempDir(), "fresh.sqlite") + s, err := Open(fresh, noSpace) + require.NoError(t, err, "a new store has nothing to upgrade") + require.NoError(t, s.Close()) + + s, err = Open(fresh, noSpace) + require.NoError(t, err, "a current store has nothing to upgrade") + require.NoError(t, s.Close()) + + s, err = Open(":memory:", noSpace) + require.NoError(t, err, "an in-memory store has no volume") + require.NoError(t, s.Close()) + + require.Zero(t, calls.Load(), "no pending upgrade means no free-space probe") +} + +func TestUpgradeFreeSpaceCheckLetsRoomyOrUnmeasuredVolumesProceed(t *testing.T) { + for name, avail := range map[string]func(string) (uint64, error){ + "fits": fixedDiskAvail(1 << 50), + "unmeasured": func(string) (uint64, error) { return 0, errors.New("statfs: not supported") }, + } { + t.Run(name, func(t *testing.T) { + path := seedOlderStore(t, 40, 50) + var probed string + s, err := Open(path, withDiskAvail(func(dir string) (uint64, error) { + probed = dir + return avail(dir) + })) + require.NoError(t, err) + require.NoError(t, s.Close()) + require.Equal(t, filepath.Dir(path), probed, "free space is read for the store's own directory") + require.Equal(t, currentSchemaVersion, rawUserVersion(t, path)) + }) + } +} + +// TestUpgradeFreeSpaceEstimateCoversMeasuredUpgrades upgrades every fixture +// at gortex scale and measures what the transition actually claimed on disk: +// the -wal file at its peak (nothing checkpoints during Open) plus the main +// file growth once those frames are checkpointed. The estimate must cover +// it; the logged ratios are what the estimator's shares are sized from. +func TestUpgradeFreeSpaceEstimateCoversMeasuredUpgrades(t *testing.T) { + upgraded := 0 + for _, source := range upgradeSources(t) { + if source.version >= currentSchemaVersion { + continue + } + upgraded++ + t.Run(source.name, func(t *testing.T) { + path := source.path + probe, err := probeStoreSchema(path) + require.NoError(t, err) + space, pending := measureUpgradeSpace(probe, currentSchemaVersion, + planSchemaMigrationWith(probe.stored, currentSchemaVersion, schemaMigrations), fixedDiskAvail(1<<50)) + require.True(t, pending) + before, err := os.Stat(path) + require.NoError(t, err) + + var walPeak int64 + sample := func() { + if info, err := os.Stat(path + "-wal"); err == nil && info.Size() > walPeak { + walPeak = info.Size() + } + } + s, err := Open(path, WithRebuild(), withDiskAvail(fixedDiskAvail(1<<50)), + withUpgradeHook(func(upgradePoint, int) { sample() })) + require.NoError(t, err) + sample() // Open's own writes after the stamp land in the same WAL + require.NoError(t, s.CheckpointWAL()) + after, err := os.Stat(path) + require.NoError(t, err) + require.NoError(t, s.Close()) + + growth := max(after.Size()-before.Size(), 0) + measured := uint64(walPeak + growth) + live := space.stats.livePages() * space.stats.pageSize + t.Logf("v%d -> v%d: live=%d wal_peak=%d (%.3f x live) growth=%d (%.3f x live); estimate wal=%d growth=%d need=%d (%.2f x measured)", + source.version, currentSchemaVersion, live, walPeak, float64(walPeak)/float64(live), growth, float64(growth)/float64(live), + space.estimate.WALPeak, space.estimate.Growth, space.estimate.Need, float64(space.estimate.Need)/float64(measured)) + require.GreaterOrEqual(t, space.estimate.Need, measured, "the precheck must never admit an upgrade the disk cannot hold") + if live >= upgradeSpaceHeadroom { + // Once the fixed allowance no longer dominates, the model alone + // must keep a margin over what the upgrade really wrote. + require.GreaterOrEqual(t, float64(space.estimate.WALPeak+space.estimate.Growth), 1.25*float64(measured), + "the shares need a 1.25x margin over the measured upgrade") + } + }) + } + if upgraded == 0 { + t.Skip("no store older than the current schema") + } +} diff --git a/internal/graph/store_sqlite/wal_copy_pause.go b/internal/graph/store_sqlite/wal_copy_pause.go index ea81b1b1d..183218844 100644 --- a/internal/graph/store_sqlite/wal_copy_pause.go +++ b/internal/graph/store_sqlite/wal_copy_pause.go @@ -290,10 +290,17 @@ func (p *walCopyPacer) beforeWrite(n int64) { } } p.pages++ - if p.rate > 0 && !p.overPacingMark && st.walCopy.editing(now) && !p.stopped() { + // A blocked warm producer is not an edit burst: its own lane must not + // consume the background editing allowance before it can make progress. + // Recheck actual writer demand for every page and inside an existing wait. + warmupWaiting := st.warmupWALWaiters.Load() > 0 && !st.writeWanted() + if p.rate > 0 && !p.overPacingMark && !warmupWaiting && st.walCopy.editing(now) && !p.stopped() { if wait := st.walCopy.take(now, n, p.rate); wait > 0 { deadline := now.Add(wait) for time.Now().Before(deadline) && !p.stopped() { + if st.warmupWALWaiters.Load() > 0 && !st.writeWanted() { + break + } // Writers can cross the pressure mark while this page waits // for budget. Do not retain a below-mark decision until the // next 256 copied pages while those writers keep appending. diff --git a/internal/graph/store_sqlite/wal_copy_warmup_wait_test.go b/internal/graph/store_sqlite/wal_copy_warmup_wait_test.go new file mode 100644 index 000000000..24dee7d0d --- /dev/null +++ b/internal/graph/store_sqlite/wal_copy_warmup_wait_test.go @@ -0,0 +1,69 @@ +package store_sqlite + +import ( + "context" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" +) + +// A warm producer can enter its boundary after an ordinary pressure copy has +// already spent the editing bucket. Its wait must let that same real copy +// finish, without raising the configured background budget or WAL mark. +func TestBlockedWarmupResumesAPacedCopy(t *testing.T) { + s, _ := warmupWALFixture(t) + lane := &fakeBuildLane{} + lane.install(s) + lane.held.Store(true) + defer lane.held.Store(false) + require.NoError(t, churnWriteOnce(s, 0)) + require.NoError(t, churnWriteOnce(s, 1)) + require.Greater(t, s.retirementLogBytes(), int64(1<<20)) + require.Less(t, s.retirementLogBytes(), int64(4<<20)) + setCopyBudget(t, 1<<20, 16<<10, 0) + t.Setenv("GORTEX_SQLITE_WAL_COPY_BUDGET_MB", "1") + s.walCopy.sawBusy(time.Now()) + startWarmupFixtureReclaimer(t, s, "32") + s.RequestWALReclaim() + require.Eventually(t, func() bool { + s.walCopy.mu.Lock() + defer s.walCopy.mu.Unlock() + return s.walCopy.tokens < 0 + }, 2*time.Second, time.Millisecond, "the real page-copy callback never spent the editing bucket") + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + require.NoError(t, s.AwaitWarmupWAL(ctx), "blocked warm producer remained on the editing copy budget") + require.Zero(t, s.warmupWALWaiters.Load()) + require.Less(t, s.retirementLogBytes(), int64(1<<20)) + var count int + require.NoError(t, s.db.QueryRow(`SELECT count(*) FROM wal_churn`).Scan(&count)) + require.Equal(t, 4000, count) +} + +func TestBlockedWarmupCopyHintKeepsForegroundEditingBudget(t *testing.T) { + s, _ := warmupWALFixture(t) + lane := &fakeBuildLane{} + lane.install(s) + lane.held.Store(true) + defer lane.held.Store(false) + setCopyBudget(t, 1<<20, 16<<10, 0) + s.warmupWALWaiters.Add(1) + defer s.warmupWALWaiters.Add(-1) + synctest.Test(t, func(t *testing.T) { + release := s.AnnounceWrite() + defer release() + s.walCopy.sawBusy(time.Now()) + reserved := s.walCopy.take(time.Now(), 32<<10, float64(1<<20)/60) + require.Greater(t, reserved, 100*time.Millisecond) + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + pacer := walCopyPacer{store: s, ctx: ctx, stop: make(chan struct{}), rate: float64(1<<20) / 60, + noPause: true, hardCapBytes: 4 << 20, walPath: s.dbPath + "-wal"} + pacer.beforeWrite(4096) + require.ErrorIs(t, ctx.Err(), context.DeadlineExceeded, "warm hint bypassed an announced foreground writer's budget") + require.GreaterOrEqual(t, pacer.budgetNs, 100*time.Millisecond) + require.False(t, pacer.overPacingMark) + }) +} diff --git a/internal/graph/store_sqlite/wal_reclaim.go b/internal/graph/store_sqlite/wal_reclaim.go index 17a8801b4..8a6f6bf36 100644 --- a/internal/graph/store_sqlite/wal_reclaim.go +++ b/internal/graph/store_sqlite/wal_reclaim.go @@ -1280,7 +1280,9 @@ func (s *Store) writeWanted() bool { // mutation queued behind them upstream of the write gate is not held by them // either. The release is idempotent. func (s *Store) AnnounceWrite() (release func()) { - return s.announceWrite(true) + release = s.announceWrite(true) + editGateClosedReclaimSeed(s) + return release } // AnnounceCheckoutRefresh retains global mutation demand while a checkout diff --git a/internal/graph/store_sqlite/wal_reclaim_adaptive_test.go b/internal/graph/store_sqlite/wal_reclaim_adaptive_test.go index ba60ccc72..5ab243e5b 100644 --- a/internal/graph/store_sqlite/wal_reclaim_adaptive_test.go +++ b/internal/graph/store_sqlite/wal_reclaim_adaptive_test.go @@ -147,12 +147,15 @@ func assertAdaptiveCreditCommittedTailRefusalOn(t *testing.T, s *Store, db *sql. } ctx, cancel := context.WithTimeout(t.Context(), time.Second) defer cancel() - completedCopy := make(chan struct{}) + completedCopies := make(chan struct{}, 2) written := make(chan error, 1) - var once sync.Once walCheckpointCallObserver = func(mode string, _ time.Time, _ time.Duration) { if mode == "PASSIVE" { - once.Do(func() { close(completedCopy) }) + select { + case completedCopies <- struct{}{}: + case <-ctx.Done(): + return + } select { case <-written: case <-ctx.Done(): @@ -162,19 +165,33 @@ func assertAdaptiveCreditCommittedTailRefusalOn(t *testing.T, s *Store, db *sql. t.Cleanup(func() { walCheckpointCallObserver = nil }) writerDone := make(chan error, 1) go func() { - select { - case <-completedCopy: - case <-ctx.Done(): - writerDone <- ctx.Err() - return + writes := 1 + if syncDelay > 0 { + writes = 2 } - err := s.writeMu.LockContext(ctx) - if err == nil { - _, err = s.writerDB.ExecContext(ctx, `UPDATE wal_churn SET payload = 'after-adaptive-copy' WHERE id = 1`) - s.writeMu.Unlock() + for ordinal := 0; ordinal < writes; ordinal++ { + select { + case <-completedCopies: + case <-ctx.Done(): + writerDone <- ctx.Err() + return + } + err := s.writeMu.LockContext(ctx) + if err == nil { + payload := "after-adaptive-copy" + if ordinal+1 < writes { + payload = "first-adaptive-tail" + } + _, err = s.writerDB.ExecContext(ctx, `UPDATE wal_churn SET payload = ? WHERE id = 1`, payload) + s.writeMu.Unlock() + } + written <- err + if err != nil { + writerDone <- err + return + } } - written <- err - writerDone <- err + writerDone <- nil }() var joinOnce sync.Once var writeErr error @@ -194,7 +211,9 @@ func assertAdaptiveCreditCommittedTailRefusalOn(t *testing.T, s *Store, db *sql. // context may legitimately have expired before a fresh admission slice. require.NotErrorIs(t, err, context.DeadlineExceeded) if syncDelay > 0 { - require.Positive(t, delayedSyncs.Load(), "the controlled checkpoint return was not exercised") + require.Equal(t, int64(2), delayedSyncs.Load(), "only one additional real PASSIVE may finish a committed tail") + require.True(t, writer.tailCopyTried) + require.LessOrEqual(t, writer.spent, budget) require.True(t, writer.readmitted, "the expired hold did not use unused credit for fresh admission") require.NotNil(t, writer.resetCtx, "fresh admission did not retain its effective scope") } diff --git a/internal/graph/store_sqlite/wal_reclaim_credit_causes_test.go b/internal/graph/store_sqlite/wal_reclaim_credit_causes_test.go new file mode 100644 index 000000000..76847f199 --- /dev/null +++ b/internal/graph/store_sqlite/wal_reclaim_credit_causes_test.go @@ -0,0 +1,149 @@ +package store_sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" +) + +type badCreditSQL struct{ *sql.DB } + +func (q badCreditSQL) QueryRowContext(ctx context.Context, _ string, _ ...any) *sql.Row { + return q.DB.QueryRowContext(ctx, `SELECT nonexistent_credit_column FROM wal_churn`) +} + +// Real checkpoint work reaches the owned child's admission exit. Errors from +// the caller, finite SQL scope, custom withdrawal or SQL itself stay distinct. +func TestWALOwnedCreditExpiryPreservesOperationAndSQLCauses(t *testing.T) { + for _, kind := range []string{"owned_credit", "unowned_credit", "parent_cancel", "parent_deadline", "parent_custom_deadline", "child_custom_deadline", "child_wrapped_deadline", "child_custom_cancel", "sql_deadline", "sql_error"} { + t.Run(kind, func(t *testing.T) { + s, db := finalBackfillFixture(t) + stopStoreBackgroundReaders(s) + synctest.Test(t, func(t *testing.T) { + custom := error(errors.New("owned credit custom withdrawal")) + if kind == "child_wrapped_deadline" { + custom = fmt.Errorf("custom withdrawal: %w", context.DeadlineExceeded) + } + parentBudget := 20 * time.Second + parentCause := error(context.DeadlineExceeded) + if kind == "parent_deadline" || kind == "parent_custom_deadline" { + parentBudget = 40 * time.Millisecond + } + if kind == "parent_custom_deadline" { + parentCause = custom + } + parent, stopParent := context.WithTimeoutCause(t.Context(), parentBudget, parentCause) + defer stopParent() + operation, cancelOperation := context.WithCancelCause(parent) + defer cancelOperation(context.Canceled) + childCause := error(context.DeadlineExceeded) + if kind == "child_custom_deadline" || kind == "child_wrapped_deadline" { + childCause = custom + } + child, stopChild := context.WithTimeoutCause(operation, 25*time.Millisecond, childCause) + defer stopChild() + child, cancelChild := context.WithCancelCause(child) + defer cancelChild(context.Canceled) + admission := child + if kind == "unowned_credit" { + var stop context.CancelFunc + admission, stop = context.WithTimeout(operation, 25*time.Millisecond) + defer stop() + } + var sqlScope context.Context + var sqlExitErr error + var copies int + previous := walReclaimCreditPassiveQueryerObserver + walReclaimCreditPassiveQueryerObserver = func(ctx context.Context, _ *Store, queryDB *sql.DB) (walCheckpointQueryer, func()) { + sqlScope = ctx + queryer := walCheckpointQueryer(queryDB) + if kind == "sql_error" { + queryer = badCreditSQL{queryDB} + } + return reclaimCopyReturnQueryer{walCheckpointQueryer: queryer, pause: func() { + copies++ + if kind == "parent_cancel" { + cancelOperation(custom) + } + if kind == "child_custom_cancel" { + cancelChild(custom) + } + time.Sleep(50 * time.Millisecond) + }}, func() { sqlExitErr = ctx.Err() } + } + defer func() { walReclaimCreditPassiveQueryerObserver = previous }() + + priorCheckpoint := walCheckpointCallObserver + walCheckpointCallObserver = func(mode string, _ time.Time, _ time.Duration) { + if kind == "sql_deadline" && mode == "PASSIVE" { + time.Sleep(walReclaimLaneBudget + time.Millisecond) + } + } + defer func() { walCheckpointCallObserver = priorCheckpoint }() + s.writeMu.Lock() + writer := newWALReclaimWriterCredit(s, time.Now()) + writer.readmitted = true + writer.resetCtx = child + writer.spent = 25 * time.Millisecond // already consumed by its original slice + defer writer.release() + _, err := writer.passive(admission, operation, db, 10*time.Millisecond, false) + require.Equal(t, 1, copies, "the observed operation must not renew credit or retry SQL") + require.NotNil(t, sqlScope) + require.False(t, writer.held) + wantSpent := 35 * time.Millisecond + switch kind { + case "parent_cancel", "child_custom_cancel": + wantSpent = 25 * time.Millisecond + } + require.Equal(t, wantSpent, writer.spent) + switch kind { + case "owned_credit": + require.NoError(t, operation.Err()) + require.NoError(t, sqlExitErr) + require.ErrorIs(t, err, errWALReclaimReadersInFlight) + require.NotErrorIs(t, err, context.DeadlineExceeded) + case "parent_cancel": + require.ErrorIs(t, err, context.Canceled) + require.Same(t, custom, context.Cause(operation)) + case "parent_deadline", "parent_custom_deadline": + require.ErrorIs(t, err, context.DeadlineExceeded) + require.ErrorIs(t, operation.Err(), context.DeadlineExceeded) + if kind == "parent_custom_deadline" { + require.Same(t, custom, context.Cause(operation)) + } + case "child_custom_cancel": + require.NoError(t, operation.Err()) + require.ErrorIs(t, err, context.Canceled) + require.Same(t, custom, context.Cause(child)) + case "child_custom_deadline", "child_wrapped_deadline": + require.NoError(t, operation.Err()) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Same(t, custom, context.Cause(child)) + case "sql_deadline": + require.NoError(t, operation.Err()) + require.ErrorIs(t, sqlExitErr, context.DeadlineExceeded) + require.ErrorIs(t, err, context.DeadlineExceeded) + case "sql_error": + require.NoError(t, operation.Err()) + require.NoError(t, sqlExitErr) + require.ErrorContains(t, err, "nonexistent_credit_column") + case "unowned_credit": + require.NoError(t, operation.Err()) + require.NoError(t, sqlExitErr) + require.ErrorIs(t, err, context.DeadlineExceeded) + } + if kind != "owned_credit" { + require.NotErrorIs(t, err, errWALReclaimReadersInFlight) + } + require.True(t, s.writeMu.TryLock(), "returned operation must release its physical writer credit") + s.writeMu.Unlock() + }) + }) + } +} diff --git a/internal/graph/store_sqlite/wal_reclaim_operation_context_test.go b/internal/graph/store_sqlite/wal_reclaim_operation_context_test.go index 9ba2e4513..fb675d980 100644 --- a/internal/graph/store_sqlite/wal_reclaim_operation_context_test.go +++ b/internal/graph/store_sqlite/wal_reclaim_operation_context_test.go @@ -142,6 +142,86 @@ func TestReclaimReadmissionSharesAggregateCredit(t *testing.T) { }) } +// A producer may append a small tail after the completed copy gives up its +// first writer slice. Final admission must use the unused slice to cover that +// tail, rather than discard an otherwise durable completed copy. +func TestReclaimReadmissionCatchesProducerTailWithinRemainingCredit(t *testing.T) { + for _, kind := range []string{"unblocked", "pinned_reader", "slow_tail"} { + t.Run(kind, func(t *testing.T) { + s, db := finalBackfillFixture(t) + stopStoreBackgroundReaders(s) + synctest.Test(t, func(t *testing.T) { + copies := 0 + var pin *sql.Tx + defer func() { + if pin != nil { + _ = pin.Rollback() + } + }() + pauseReclaimCopyReturn(t, func() { + copies++ + if copies != 1 { + if kind == "slow_tail" { + time.Sleep(300 * time.Millisecond) + } + return + } + time.Sleep(300 * time.Millisecond) + if kind == "pinned_reader" { + var err error + pin, err = s.db.BeginTx(t.Context(), &sql.TxOptions{ReadOnly: true}) + require.NoError(t, err) + var payload string + require.NoError(t, pin.QueryRow(`SELECT payload FROM wal_churn WHERE id=1`).Scan(&payload)) + } + s.writeMu.Lock() + _, err := s.writerDB.Exec(`UPDATE wal_churn SET payload='producer-tail' WHERE id=1`) + s.writeMu.Unlock() + require.NoError(t, err) + }) + operation, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + s.writeMu.Lock() + writer := newWALReclaimWriterCredit(s, time.Now()) + defer writer.release() + original, stop := context.WithTimeout(operation, 50*time.Millisecond) + defer stop() + _, err := writer.passive(original, operation, db, 25*time.Millisecond, false) + if kind == "slow_tail" { + require.NoError(t, operation.Err(), "only the owned credit expired") + require.ErrorIs(t, err, errWALReclaimReadersInFlight) + require.NotErrorIs(t, err, context.DeadlineExceeded) + require.Equal(t, 2, copies) + require.True(t, writer.readmitted) + require.False(t, writer.held) + require.Equal(t, 50*time.Millisecond, writer.spent, "tail copy renewed the aggregate allowance") + return + } + if pin != nil { + require.ErrorIs(t, err, errWALReclaimReadersInFlight) + } else { + require.NoError(t, err, "the completed copy's new tail never used its remaining writer credit") + } + require.True(t, writer.readmitted) + require.Equal(t, 2, copies, "tail completion must make exactly one additional real PASSIVE") + resetCtx := writer.resetContext(original) + deadline, bounded := resetCtx.Deadline() + require.True(t, bounded) + require.Equal(t, 50*time.Millisecond-writer.spent, time.Until(deadline)) + if pin == nil { + _, err = s.resetWALForReclaim(resetCtx) + require.NoError(t, err) + } + writer.release() + require.Equal(t, 25*time.Millisecond, writer.spent) + var payload string + require.NoError(t, s.db.QueryRow(`SELECT payload FROM wal_churn WHERE id=1`).Scan(&payload)) + require.Equal(t, "producer-tail", payload) + }) + }) + } +} + func TestReclaimReadmissionDoesNotReviveCancellationOrSpentCredit(t *testing.T) { for _, kind := range []string{"spent", "cancel_cause", "writer_demand"} { t.Run(kind, func(t *testing.T) { diff --git a/internal/graph/store_sqlite/wal_reclaim_writer_credit.go b/internal/graph/store_sqlite/wal_reclaim_writer_credit.go index fe7832dac..640d28271 100644 --- a/internal/graph/store_sqlite/wal_reclaim_writer_credit.go +++ b/internal/graph/store_sqlite/wal_reclaim_writer_credit.go @@ -19,6 +19,7 @@ type walReclaimWriterCredit struct { resetCtx context.Context cancelReset context.CancelFunc readmitted bool + tailCopyTried bool yieldToWriters bool slowTail *walReclaimSlowTail } @@ -50,6 +51,17 @@ func (w *walReclaimWriterCredit) resetContext(original context.Context) context. return original } +// admissionError classifies only a known owned credit/admission failure. +// SQL failures never pass through this path, and both operation scopes must +// still be live before a child deadline becomes the lost-credit refusal. +// Sentinel identity is deliberate: a wrapped custom cause is not our timer. +func (w *walReclaimWriterCredit) admissionError(creditCtx, operationCtx, sqlCtx context.Context, err error) error { + if w.readmitted && w.resetCtx != nil && creditCtx == w.resetCtx && operationCtx.Err() == nil && sqlCtx.Err() == nil && err == context.DeadlineExceeded && creditCtx.Err() == context.DeadlineExceeded && context.Cause(creditCtx) == context.DeadlineExceeded { + return errWALReclaimReadersInFlight + } + return err +} + // PASSIVE uses the separate checkpoint connection and never takes SQLite's // writer lock. Its WAL-copy/database-sync ordering is unchanged. Fast passes // retain a short writer gap so queued writes cannot starve reset admission; @@ -153,7 +165,7 @@ func (w *walReclaimWriterCredit) passive(ctx, operationCtx context.Context, db * if ctx.Err() != nil { unused := allowance - w.spent if w.readmitted || unused <= 0 || operationCtx.Err() != nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) || !errors.Is(context.Cause(ctx), context.DeadlineExceeded) { - return result, ctx.Err() + return result, w.admissionError(ctx, operationCtx, opCtx, ctx.Err()) } if demand := w.store.writeDemandCause(); w.yieldToWriters && demand != nil { noteWALReclaimDemand(operationCtx, demand) @@ -174,13 +186,29 @@ func (w *walReclaimWriterCredit) passive(ctx, operationCtx context.Context, db * w.cancelReset = func() { stopYield(); cancel() } } if err := w.store.writeMu.lockReclaim(admissionCtx); err != nil { - return result, err + return result, w.admissionError(admissionCtx, operationCtx, opCtx, err) } w.since, w.held = time.Now(), true if w.store.bulkConn != nil && !leaseOverride { return result, errWALCheckpointDeferredBulk } snap, ok := readWALIndexSnapshot(w.store.dbPath) + // A producer may append after the durable slow copy released its first + // slice. An admitted final slice may finish that tail once, under exactly + // its remaining deadline. It cannot renew admission or writer credit. + if ok && snap.MxFrame != snap.NBackfill && w.readmitted && !w.tailCopyTried { + w.tailCopyTried = true + if limit, bounded := admissionCtx.Deadline(); bounded && time.Until(limit) > 0 { + result, err = w.passive(admissionCtx, operationCtx, db, time.Until(limit), leaseOverride) + if err != nil && !errors.Is(err, errSQLiteCheckpointIncomplete) { + return result, err + } + if !w.held { + return result, errWALReclaimReadersInFlight + } + snap, ok = readWALIndexSnapshot(w.store.dbPath) + } + } if !ok || snap.MxFrame != snap.NBackfill { return result, errWALReclaimReadersInFlight } diff --git a/internal/graph/store_sqlite/wal_shrink.go b/internal/graph/store_sqlite/wal_shrink.go index 93d745047..42bd70c92 100644 --- a/internal/graph/store_sqlite/wal_shrink.go +++ b/internal/graph/store_sqlite/wal_shrink.go @@ -426,7 +426,7 @@ func (s *Store) shrinkWAL(ctx context.Context, ckptDB *sql.DB) (slices int, shru } // A slice is an ftruncate under the writer: it waits for a queued // write and for the gaps between edit cycles. - for s.writeWanted() || (s.cycleYieldEnabled() && s.buildLaneBusy()) { + for s.writeWanted() || (s.cycleYieldEnabled() && s.buildLaneBusy() && s.warmupWALWaiters.Load() == 0) { select { case <-ctx.Done(): return slices, shrunk, ctx.Err() diff --git a/internal/graph/store_sqlite/warmup_wal_wait_test.go b/internal/graph/store_sqlite/warmup_wal_wait_test.go new file mode 100644 index 000000000..539ae2a5b --- /dev/null +++ b/internal/graph/store_sqlite/warmup_wal_wait_test.go @@ -0,0 +1,210 @@ +package store_sqlite + +import ( + "context" + "database/sql" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func warmupWALFixture(t *testing.T) (*Store, *sql.DB) { + t.Helper() + s, db := finalBackfillFixture(t) + stopStoreBackgroundReaders(s) + s.walReclaim.update(func(st *WALReclaimStats) { + st.ThresholdBytes = 1 << 20 + st.CeilingBytes = 32 << 20 + }) + oldPoll := retirementWALWaitPoll + retirementWALWaitPoll = 10 * time.Millisecond + t.Cleanup(func() { retirementWALWaitPoll = oldPoll }) + return s, db +} + +func startWarmupFixtureReclaimer(t *testing.T, s *Store, ceilingMB string) { + t.Helper() + t.Setenv("GORTEX_SQLITE_WAL_RECLAIM_MB", "1") + t.Setenv("GORTEX_SQLITE_WAL_RECLAIM_CEILING_MB", ceilingMB) + oldFloor := walReclaimCeilingFloor + walReclaimCeilingFloor = 1 << 20 + t.Cleanup(func() { walReclaimCeilingFloor = oldFloor }) + setWALReclaimCadence(t, time.Hour, 10*time.Millisecond, 20*time.Millisecond) + stop := make(chan struct{}) + s.stopCheckpoint = stop + join := s.startWALReclaimLoop(s.dbPath + "-wal") + t.Cleanup(func() { + close(stop) + join() + }) +} + +// RESTART and the next commit can reuse a correctly reset allocation larger +// than the pressure trigger. Requiring the physical file below that trigger +// would strand a valid warm store even though the next bounded chunk fits. +func TestWarmupWALWaitReusesExistingResetAllocation(t *testing.T) { + s, db := warmupWALFixture(t) + _, err := checkpointWALOnceOn(t.Context(), db, "RESTART") + require.NoError(t, err) + s.writeMu.Lock() + _, err = s.writerDB.Exec(`UPDATE wal_churn SET payload='reused-allocation' WHERE id=1`) + s.writeMu.Unlock() + require.NoError(t, err) + physical := walFileSize(s.dbPath + "-wal") + require.Greater(t, physical, int64(1<<20)) + require.Less(t, s.retirementLogBytes(), int64(1<<20)) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + require.NoError(t, s.AwaitWarmupWAL(ctx)) + require.NoError(t, churnWriteOnce(s, 0)) + require.LessOrEqual(t, walFileSize(s.dbPath+"-wal"), int64(32<<20)) +} + +func TestWarmupWALWaitKeepsPhysicalHeadroomAfterLogicalReset(t *testing.T) { + s, db := warmupWALFixture(t) + _, err := checkpointWALOnceOn(t.Context(), db, "RESTART") + require.NoError(t, err) + s.writeMu.Lock() + _, err = s.writerDB.Exec(`UPDATE wal_churn SET payload='logical-reset-only' WHERE id=1`) + s.writeMu.Unlock() + require.NoError(t, err) + s.walReclaim.update(func(st *WALReclaimStats) { st.CeilingBytes = 8 << 20 }) + before := walFileSize(s.dbPath + "-wal") + require.Greater(t, before, int64(4<<20)) + require.Less(t, s.retirementLogBytes(), int64(1<<20)) + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + require.ErrorIs(t, s.AwaitWarmupWAL(ctx), context.DeadlineExceeded) + require.Equal(t, before, walFileSize(s.dbPath+"-wal"), "logical reset bypassed physical headroom") + startWarmupFixtureReclaimer(t, s, "8") + progress, stop := context.WithTimeout(t.Context(), 2*time.Second) + defer stop() + require.NoError(t, s.AwaitWarmupWAL(progress), "production nudge must reclaim an otherwise reusable allocation") + require.LessOrEqual(t, walFileSize(s.dbPath+"-wal"), int64(4<<20)) +} + +// Exercise the production large-file shrink branch as well as the small-file +// TRUNCATE path above. Extending only dead space after a real restart leaves a +// valid reusable WAL prefix; no synthetic frontier or successful copy is forged. +func TestWarmupWALWaitReclaimsLargeRetainedAllocation(t *testing.T) { + s, db := warmupWALFixture(t) + _, err := checkpointWALOnceOn(t.Context(), db, "RESTART") + require.NoError(t, err) + s.writeMu.Lock() + _, err = s.writerDB.Exec(`UPDATE wal_churn SET payload='large-retained-allocation' WHERE id=1`) + if err == nil { + err = os.Truncate(s.dbPath+"-wal", walShrinkInPlaceBytes+walShrinkKeepBytes) + } + s.writeMu.Unlock() + require.NoError(t, err) + require.Greater(t, walFileSize(s.dbPath+"-wal"), int64(512<<20)) + require.Less(t, s.retirementLogBytes(), int64(1<<20)) + lane := &fakeBuildLane{} + lane.install(s) + lane.held.Store(true) + defer lane.held.Store(false) + releaseWriter := s.AnnounceWrite() + defer func() { releaseWriter() }() + startWarmupFixtureReclaimer(t, s, "1024") + refused, stop := context.WithTimeout(t.Context(), 100*time.Millisecond) + require.ErrorIs(t, s.AwaitWarmupWAL(refused), context.DeadlineExceeded) + stop() + require.Zero(t, s.warmupWALWaiters.Load(), "refused producer retained its lane exception") + require.Zero(t, s.WALReclaimStats().ShrinkSlices, "warm wait bypassed foreground writer priority") + require.Equal(t, walShrinkInPlaceBytes+walShrinkKeepBytes, walFileSize(s.dbPath+"-wal")) + releaseWriter() + releaseWriter = func() {} + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + require.NoError(t, s.AwaitWarmupWAL(ctx)) + require.Zero(t, s.warmupWALWaiters.Load(), "admitted producer retained its lane exception") + require.Eventually(t, func() bool { return s.WALReclaimStats().ShrinkSlices > 0 }, time.Second, time.Millisecond, + "production nudge did not shrink retained dead space") + require.LessOrEqual(t, walFileSize(s.dbPath+"-wal"), int64(512<<20)) + var payload string + require.NoError(t, s.db.QueryRow(`SELECT payload FROM wal_churn WHERE id=1`).Scan(&payload)) + require.Equal(t, "large-retained-allocation", payload) +} + +// This is a producer boundary: no next chunk is allocated while an older +// reader prevents progress. Cancellation refuses it; a real reader release +// plus checkpoint admits the next actual bounded write. +func TestWarmupWALWaitGatesNextAllocationUntilReaderProgress(t *testing.T) { + for _, kind := range []string{"cancel", "deadline", "reader_release"} { + t.Run(kind, func(t *testing.T) { + s, _ := warmupWALFixture(t) + pin, err := s.db.BeginTx(t.Context(), &sql.TxOptions{ReadOnly: true}) + require.NoError(t, err) + defer func() { _ = pin.Rollback() }() + var count int + require.NoError(t, pin.QueryRow(`SELECT count(*) FROM wal_churn`).Scan(&count)) + require.NoError(t, churnWriteOnce(s, 0)) + require.NoError(t, churnWriteOnce(s, 1)) + require.Greater(t, s.retirementLogBytes(), int64(1<<20)) + before := walFileSize(s.dbPath + "-wal") + startWarmupFixtureReclaimer(t, s, "32") + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if kind == "deadline" { + var stop context.CancelFunc + ctx, stop = context.WithTimeout(ctx, 100*time.Millisecond) + defer stop() + } + var allocations atomic.Int64 + done := make(chan error, 1) + go func() { + err := s.AwaitWarmupWAL(ctx) + if err == nil { + allocations.Add(1) + err = churnWriteOnce(s, 1) + } + done <- err + }() + joined := false + defer func() { + cancel() + if !joined { + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("owned warmup boundary did not join") + } + } + }() + require.Eventually(t, func() bool { return s.walReclaimRequestedAt.Load() != 0 }, 2*time.Second, time.Millisecond, + "warmup did not request the existing production reclaim") + require.Zero(t, allocations.Load()) + require.Equal(t, before, walFileSize(s.dbPath+"-wal"), "refused producer allocated another chunk") + switch kind { + case "reader_release": + require.NoError(t, pin.Rollback()) + case "cancel": + cancel() + } + select { + case err = <-done: + joined = true + case <-time.After(2 * time.Second): + cancel() + t.Fatal("warmup boundary did not join") + } + if kind == "reader_release" { + require.NoError(t, err) + require.EqualValues(t, 1, allocations.Load()) + require.LessOrEqual(t, walFileSize(s.dbPath+"-wal"), int64(32<<20)) + } else { + want := error(context.Canceled) + if kind == "deadline" { + want = context.DeadlineExceeded + } + require.ErrorIs(t, err, want) + require.Zero(t, allocations.Load()) + require.Equal(t, before, walFileSize(s.dbPath+"-wal")) + } + require.Zero(t, s.warmupWALWaiters.Load(), "finished boundary retained its lane exception") + }) + } +} diff --git a/internal/indexer/affected_by_bound_test.go b/internal/indexer/affected_by_bound_test.go index 95bcd9fb4..a8c0cd4f4 100644 --- a/internal/indexer/affected_by_bound_test.go +++ b/internal/indexer/affected_by_bound_test.go @@ -904,9 +904,9 @@ func TestPointMutationWithDeferredGlobalPassesPublishesNoVerdict(t *testing.T) { "a mutation that never ran the bounded pass published a verdict: %+v", result.DerivedFanout) } -// The daemon does NOT run the standalone point executor. MultiWatcher replaces -// the seam with MultiIndexer.incrementalPointRepoRaw (multi_watcher.go), which -// reaches the bounded pass through incrementalReindexRepoRawMode instead. Drive +// The daemon does NOT run the standalone point executor. Its registered +// Indexer's repositoryMutationOwner selects incrementalReindexRepoRawMode +// while retaining the watcher's relation-tier publication callback. Drive // that arm end to end so a change to the MultiIndexer executor's receipt or // catch-up handling cannot silently drop the verdict the shipped daemon // publishes. @@ -919,7 +919,18 @@ func TestDaemonPointExecutorReportsTheDerivedFanoutCut(t *testing.T) { multi := NewMultiIndexer(store, newTestRegistry(), nil, nil, zap.NewNop()) multi.repos[prefix] = &RepoMetadata{RepoPrefix: prefix, RootPath: dir} multi.indexers[prefix] = idx + // Keep the custom fan-out fixture, but bind the owner that the production + // MultiIndexer factory installs before registering an Indexer. + idx.repositoryMutationOwner = multi idx.attachRepositoryMutationCoordinator(multi.repositoryMutationCoordinator(prefix)) + var crossCalls atomic.Int32 + priorCatchup := idx.incrementalCatchupHook + idx.incrementalCatchupHook = func(kind string, files []string) { + priorCatchup(kind, files) + if kind == "cross_repo" { + crossCalls.Add(1) + } + } mw, err := NewMultiWatcher(multi, map[string]config.WatchConfig{ prefix: {Enabled: true, DebounceMs: 5}, @@ -927,8 +938,15 @@ func TestDaemonPointExecutorReportsTheDerivedFanoutCut(t *testing.T) { require.NoError(t, err) w := mw.watchers[prefix] require.NotNil(t, w, "MultiWatcher registered no watcher for %s", prefix) - require.NotNil(t, w.pointReindexRaw, - "the daemon arm under test is not installed; this would silently run the standalone executor") + // Explicit admission drives the real executor without starting fsnotify. + // Stop must close its handle without waiting for a loop that never ran. + w.degradedNoFsnotify = true + t.Cleanup(func() { require.NoError(t, w.Stop()) }) + current := w.currentMutationIndexer() + require.NotNil(t, current) + require.NotNil(t, current.repositoryMutationOwner, + "the daemon owner must be installed; otherwise this silently runs the standalone executor") + require.Same(t, multi, current.repositoryMutationOwner) bumpMtime(t, defPaths[0], "package p\n\nfunc F0(x int, y int) int { return x + y }\n") ticket, err := w.EnqueueFileMutation(context.Background(), defPaths[0]) @@ -944,6 +962,7 @@ func TestDaemonPointExecutorReportsTheDerivedFanoutCut(t *testing.T) { require.Len(t, truncationEntries(observed), 1, "the daemon arm did not truncate; the assertions below would be vacuous") + require.EqualValues(t, 1, crossCalls.Load(), "the real daemon resolver tail must run, not the standalone fallback") require.Empty(t, obs.uncarriedCuts()) require.NoError(t, result.Err) require.True(t, result.Reindexed, "the daemon-arm mutation did not apply: %+v", result) diff --git a/internal/indexer/content_match.go b/internal/indexer/content_match.go new file mode 100644 index 000000000..729ec7152 --- /dev/null +++ b/internal/indexer/content_match.go @@ -0,0 +1,32 @@ +package indexer + +import ( + "path/filepath" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/pathkey" +) + +// IndexedContentMatches reports whether src, bytes the caller already read +// for the repo-relative relPath, are the content the index last recorded for +// that file, so the spans of the file's stored nodes describe src exactly. It +// compares the persisted content identity (size and BLAKE3 over the +// transformed bytes) and answers false whenever the backend cannot tell. +func (idx *Indexer) IndexedContentMatches(relPath string, src []byte) bool { + reader, ok := idx.graph.(graph.FileMetaPathReader) + if !ok { + return false + } + relPath = pathkey.Normalize(filepath.ToSlash(relPath)) + graphPath := idx.prefixPath(relPath) + rows, err := reader.FileMetasByPaths(idx.repoPrefix, []string{graphPath}) + if err != nil { + return false + } + row, ok := rows[graphPath] + if !ok || row.ContentHash == "" { + return false + } + src = idx.transforms.run(relPath, src) + return len(src) == row.Size && contentHashForSource(src) == row.ContentHash +} diff --git a/internal/indexer/content_match_test.go b/internal/indexer/content_match_test.go new file mode 100644 index 000000000..a0c01ff00 --- /dev/null +++ b/internal/indexer/content_match_test.go @@ -0,0 +1,30 @@ +package indexer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/config" +) + +func TestIndexedContentMatches(t *testing.T) { + dir := t.TempDir() + plain := []byte("package main\n\nfunc A() {}\n") + // The recorded identity is over transformed bytes, so a raw read that + // still carries the BOM the index stripped must match too. + bom := append([]byte{0xef, 0xbb, 0xbf}, []byte("package main\n\nfunc B() {}\n")...) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), plain, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.go"), bom, 0o644)) + idx := newExtractSourceTestIndexer(t, config.Default().Index) + _, err := idx.Index(dir) + require.NoError(t, err) + + require.True(t, idx.IndexedContentMatches("a.go", plain)) + require.True(t, idx.IndexedContentMatches("b.go", bom)) + require.False(t, idx.IndexedContentMatches("a.go", []byte("package main\n\n\nfunc A() {}\n")), + "bytes the index has not seen must not vouch for stored spans") + require.False(t, idx.IndexedContentMatches("missing.go", plain), "an untracked file has no recorded identity") +} diff --git a/internal/indexer/crash_isolation.go b/internal/indexer/crash_isolation.go index 70b65b640..c014984f8 100644 --- a/internal/indexer/crash_isolation.go +++ b/internal/indexer/crash_isolation.go @@ -367,6 +367,9 @@ func (idx *Indexer) extractFileCtxWithRawLease( // and has since been fixed, drop the stale entry. q.Forget(relPath) result = &parser.ExtractionResult{Nodes: res.Nodes, Edges: res.Edges} + if res.ParseChecked && res.ParseErrors >= 0 { + result.ParseChecked, result.ParseErrors = true, res.ParseErrors + } if res.HasParseErr { stampParseErrorCount(result.Nodes, res.ParseErrors) } diff --git a/internal/indexer/extract_source.go b/internal/indexer/extract_source.go index 506782209..aa38180aa 100644 --- a/internal/indexer/extract_source.go +++ b/internal/indexer/extract_source.go @@ -62,12 +62,6 @@ func (idx *Indexer) ExtractSource(ctx context.Context, filePath string, src []by return nil, fmt.Errorf("prepared source exceeds bounded extraction limit (%d bytes)", limit) } - var pool *crashpool.Pool - var quarantine *crashpool.Quarantine - if idx.crashIsolationEnabled() { - pool, quarantine = idx.sharedParsePool() - } - nativeAdmission := newNativeParseExtractionAdmission(0, nil, idx.nativeParseAdmission.Load()) rawLease, err := acquireParseAdmission( ctx, int64(len(prepared)), 0, nil, idx.parseAdmission.Load(), ) @@ -75,10 +69,34 @@ func (idx *Indexer) ExtractSource(ctx context.Context, filePath string, src []by return nil, fmt.Errorf("source admission: %w", err) } defer rawLease.Release() + nativeAdmission := newNativeParseExtractionAdmission(0, nil, idx.nativeParseAdmission.Load()) + return idx.extractSourceWith( + ctx, rawLease, nativeAdmission, + path, filePath, language, extractor, prepared, + ) +} +// extractSourceWith parses prepared caller-owned bytes admitted under +// rawLease, with the native-tree admission, crash isolation and index +// safeguards of a normal parse. Interactive callers admit the bytes against +// a reserved budget so a full shared budget cannot hold them behind bulk +// indexing. +func (idx *Indexer) extractSourceWith( + ctx context.Context, + rawLease *parseAdmissionLease, + nativeAdmission *nativeParseExtractionAdmission, + path, relPath, language string, + extractor parser.Extractor, + src []byte, +) (*parser.ExtractionResult, error) { + var pool *crashpool.Pool + var quarantine *crashpool.Quarantine + if idx.crashIsolationEnabled() { + pool, quarantine = idx.sharedParsePool() + } result, skipped, err := idx.extractFileCtxWithRawLease( ctx, nativeAdmission, rawLease, pool, quarantine, - path, filePath, language, extractor, prepared, + path, relPath, language, extractor, src, ) if err != nil { if result != nil { diff --git a/internal/indexer/grep.go b/internal/indexer/grep.go index e70548eff..eeb05f152 100644 --- a/internal/indexer/grep.go +++ b/internal/indexer/grep.go @@ -2,9 +2,14 @@ package indexer import ( "context" + "errors" + "io/fs" + "os" + "path/filepath" "regexp" "regexp/syntax" "sort" + "sync" "github.com/zzet/gortex/internal/search/trigram" ) @@ -105,6 +110,7 @@ func (idx *Indexer) warmTrigramSearcher() *trigram.Searcher { var release func() var bytes int64 if searcher != nil { + idx.applyToolWritesLocked() release = idx.trigramReleaseLocked() bytes = searcher.ApproxIndexBytes() } @@ -139,6 +145,10 @@ func (idx *Indexer) canPatchTrigramLocked() bool { // // Paths accumulate only while a searcher is live; with none built there is // nothing to patch and the next search builds from the current corpus. +// +// Tool-write notes outlive this call: the pass moves the generation only +// after it returns, and until then a live searcher learns of an edit from +// its note alone. With no searcher, a settled note has nothing left to do. func (idx *Indexer) noteTrigramDirty(paths ...string) { if idx == nil || len(paths) == 0 { return @@ -146,6 +156,7 @@ func (idx *Indexer) noteTrigramDirty(paths ...string) { idx.trigramMu.Lock() defer idx.trigramMu.Unlock() if idx.trigramSearcher == nil { + idx.toolWrites.retire(idx.toolWrites.snapshot(), idx.toolWriteSettled) return } if idx.trigramDirty == nil { @@ -158,6 +169,95 @@ func (idx *Indexer) noteTrigramDirty(paths ...string) { } } +// noteToolWrite records that a tool has just written a repo-relative path +// that no index pass has read yet. The next search reads the file's current +// bytes: a live searcher patches its postings first, and a streaming search +// walks the file even before the index tracks it. +func (idx *Indexer) noteToolWrite(rel string) { + if idx == nil || rel == "" { + return + } + idx.toolWrites.note(filepath.ToSlash(rel)) +} + +// applyToolWritesLocked reads every noted tool write into the live searcher, +// then retires the notes that have settled. The caller holds trigramMu and a +// live searcher. +func (idx *Indexer) applyToolWritesLocked() { + applied := idx.toolWrites.snapshot() + if len(applied) == 0 { + return + } + for rel := range applied { + idx.trigramSearcher.Update(rel) + } + idx.toolWrites.retire(applied, idx.toolWriteSettled) +} + +// toolWriteSettled reports whether a note on rel is no longer needed once the +// searcher holds its bytes, or once there is no searcher: a tracked file is +// covered by its reindex, which marks it dirty, and by every build, and a +// missing file has nothing left to find. A file no pass has tracked yet keeps +// its note, so knownFilePaths still offers it to streaming searches and +// rebuilds. +func (idx *Indexer) toolWriteSettled(rel string) bool { + idx.mtimeMu.RLock() + _, tracked := idx.fileMtimes[rel] + idx.mtimeMu.RUnlock() + if tracked { + return true + } + _, err := os.Stat(filepath.Join(idx.rootPath, filepath.FromSlash(rel))) + return errors.Is(err, fs.ErrNotExist) +} + +// toolWriteSet holds the paths tools wrote that the text index has not read +// yet, each with the sequence number of its newest note, so a note made +// while a search applied an older one is not retired with it. +type toolWriteSet struct { + mu sync.Mutex + seq uint64 + paths map[string]uint64 +} + +func (s *toolWriteSet) note(rel string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.paths == nil { + s.paths = make(map[string]uint64) + } + s.seq++ + s.paths[rel] = s.seq +} + +func (s *toolWriteSet) snapshot() map[string]uint64 { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.paths) == 0 { + return nil + } + out := make(map[string]uint64, len(s.paths)) + for rel, seq := range s.paths { + out[rel] = seq + } + return out +} + +// retire drops each applied note that has not been made again since and that +// done says is no longer needed. +func (s *toolWriteSet) retire(applied map[string]uint64, done func(rel string) bool) { + for rel, seq := range applied { + if !done(rel) { + continue + } + s.mu.Lock() + if s.paths[rel] == seq { + delete(s.paths, rel) + } + s.mu.Unlock() + } +} + // hasWarmTrigramSearcher reports whether this repo already holds a built // searcher, without building one. Callers that can stream instead use it // to keep a broad fan-out from building an index per repo. @@ -275,20 +375,27 @@ func (idx *Indexer) GrepRegexpPaths(pattern string, prefixes []string, limit int return matches, nil } -// knownFilePaths returns a sorted snapshot of the repo's indexed files — -// the corpus every streaming search walks, and the one Build indexes. +// knownFilePaths returns a sorted snapshot of the repo's indexed files plus +// the files tools wrote that no pass has tracked yet — the corpus every +// streaming search walks, and the one Build indexes. // -// Safe to call while holding trigramMu: mtimeMu is a leaf lock, and the -// build path took it in exactly this order before. +// Safe to call while holding trigramMu: mtimeMu and the tool-write lock are +// leaf locks, and the build path took mtimeMu in exactly this order before. func (idx *Indexer) knownFilePaths() []string { if idx == nil { return nil } + pending := idx.toolWrites.snapshot() idx.mtimeMu.RLock() - paths := make([]string, 0, len(idx.fileMtimes)) + paths := make([]string, 0, len(idx.fileMtimes)+len(pending)) for rel := range idx.fileMtimes { paths = append(paths, rel) } + for rel := range pending { + if _, tracked := idx.fileMtimes[rel]; !tracked { + paths = append(paths, rel) + } + } idx.mtimeMu.RUnlock() sort.Strings(paths) return paths diff --git a/internal/indexer/grep_bounded.go b/internal/indexer/grep_bounded.go index 5b570460f..f9c739313 100644 --- a/internal/indexer/grep_bounded.go +++ b/internal/indexer/grep_bounded.go @@ -27,6 +27,7 @@ func (idx *Indexer) GrepTextBounded( var release func() var bytes int64 if warm { + idx.applyToolWritesLocked() release = idx.trigramReleaseLocked() bytes = searcher.ApproxIndexBytes() } @@ -62,6 +63,7 @@ func (idx *Indexer) GrepLiteralBounded( var release func() var bytes int64 if warm { + idx.applyToolWritesLocked() release = idx.trigramReleaseLocked() bytes = searcher.ApproxIndexBytes() } diff --git a/internal/indexer/grep_tool_write_test.go b/internal/indexer/grep_tool_write_test.go new file mode 100644 index 000000000..fc81207d4 --- /dev/null +++ b/internal/indexer/grep_tool_write_test.go @@ -0,0 +1,151 @@ +package indexer + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/search/trigram" +) + +// toolWriteTestIndexer indexes main.go and other.go with a trigram budget +// that builds on first demand, or never when maxLive is zero. +func toolWriteTestIndexer(t *testing.T, maxLive int) (string, *Indexer) { + t.Helper() + now := time.Unix(1_700_000_000, 0) + dir := t.TempDir() + writeTestFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc Original() int { return 0 }\n") + writeTestFile(t, filepath.Join(dir, "other.go"), "package main\n\nfunc Other() {}\n") + return dir, &Indexer{ + rootPath: dir, + fileMtimes: map[string]int64{"main.go": 1, "other.go": 1}, + trigramBudgetOverride: newTestTrigramBudget(time.Hour, maxLive, &now), + } +} + +func requireOneMatch(t *testing.T, matches []trigram.Match, path string, line int) { + t.Helper() + require.Len(t, matches, 1) + require.Equal(t, path, matches[0].Path) + require.Equal(t, line, matches[0].Line) +} + +// TestTextSearchSeesToolWriteBeforeReindex covers a file a tool has written +// and no index pass has read yet: every text search answers from its new +// bytes, whether a live searcher serves it or a streaming scan does. +func TestTextSearchSeesToolWriteBeforeReindex(t *testing.T) { + ctx := context.Background() + + t.Run("a live searcher patches an edited file in every search kind", func(t *testing.T) { + dir, idx := toolWriteTestIndexer(t, 3) + require.NotNil(t, idx.warmTrigramSearcher()) + built, gen := idx.trigramSearcher, idx.indexGen.Load() + // No literal contains another, so each old line is gone, not shadowed. + searches := []struct { + literal string + search func(literal string) []trigram.Match + }{ + {"EditSeenByGrepText", func(literal string) []trigram.Match { return idx.GrepText(literal, 10) }}, + {"EditSeenByBoundedText", func(literal string) []trigram.Match { + m, _ := idx.GrepTextBounded(ctx, literal, 10, 0) + return m + }}, + {"EditSeenByBoundedLiteral", func(literal string) []trigram.Match { + m, _ := idx.GrepLiteralBounded(ctx, literal, 10, 0) + return m + }}, + {"EditSeenByRegexp", func(literal string) []trigram.Match { + m, err := idx.GrepRegexp(literal+`\(\)`, "", 10) + require.NoError(t, err) + return m + }}, + } + previous := "Original" + for _, tc := range searches { + writeTestFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc "+tc.literal+"() int { return 0 }\n") + idx.noteToolWrite("main.go") + requireOneMatch(t, tc.search(tc.literal), "main.go", 3) + require.Empty(t, tc.search(previous), tc.literal+": the replaced line is gone") + require.Empty(t, idx.toolWrites.snapshot(), tc.literal+": a tracked file's note retires once applied") + previous = tc.literal + } + require.Same(t, built, idx.trigramSearcher, "a tool write patches, it never rebuilds") + require.Equal(t, gen, idx.indexGen.Load()) + }) + + t.Run("a new file is found before any pass tracks it, then handed to the pass", func(t *testing.T) { + dir, idx := toolWriteTestIndexer(t, 3) + require.NotNil(t, idx.warmTrigramSearcher()) + writeTestFile(t, filepath.Join(dir, "added.go"), "package main\n\nfunc AddedByTool() {}\n") + idx.noteToolWrite("added.go") + + requireOneMatch(t, idx.GrepText("AddedByTool", 10), "added.go", 3) + // A path-scoped search always streams the known files. + requireOneMatch(t, idx.GrepTextPaths("AddedByTool", []string{"added.go"}, 10), "added.go", 3) + require.Contains(t, idx.toolWrites.snapshot(), "added.go", "an untracked file keeps its note") + + // The pass tracks the file: the next search retires its note. + idx.mtimeMu.Lock() + idx.fileMtimes["added.go"] = 2 + idx.mtimeMu.Unlock() + idx.noteTrigramDirty("added.go") + idx.indexGen.Add(1) + requireOneMatch(t, idx.GrepText("AddedByTool", 10), "added.go", 3) + require.Empty(t, idx.toolWrites.snapshot()) + }) + + t.Run("an edit the pass has read is found before the pass moves the generation", func(t *testing.T) { + dir, idx := toolWriteTestIndexer(t, 3) + require.NotNil(t, idx.warmTrigramSearcher()) + writeTestFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc EditedByTool() int { return 0 }\n") + idx.noteToolWrite("main.go") + // The pass has marked the file dirty, but the dirty set applies only + // once the generation moves, so this search has the note alone. + idx.noteTrigramDirty("main.go") + requireOneMatch(t, idx.GrepText("EditedByTool", 10), "main.go", 3) + require.Empty(t, idx.GrepText("Original", 10)) + idx.indexGen.Add(1) + requireOneMatch(t, idx.GrepText("EditedByTool", 10), "main.go", 3) + require.Empty(t, idx.toolWrites.snapshot()) + }) + + t.Run("a streaming search walks a new file", func(t *testing.T) { + dir, idx := toolWriteTestIndexer(t, 0) + writeTestFile(t, filepath.Join(dir, "added.go"), "package main\n\nfunc AddedByTool() {}\n") + idx.noteToolWrite("added.go") + requireOneMatch(t, idx.GrepText("AddedByTool", 10), "added.go", 3) + require.Nil(t, idx.trigramSearcher, "the budget forbids a build") + + // With no searcher to apply it, the note retires once the pass tracks + // the file. + idx.mtimeMu.Lock() + idx.fileMtimes["added.go"] = 2 + idx.mtimeMu.Unlock() + idx.noteTrigramDirty("added.go") + idx.indexGen.Add(1) + require.Empty(t, idx.toolWrites.snapshot()) + requireOneMatch(t, idx.GrepText("AddedByTool", 10), "added.go", 3) + }) + + t.Run("a deleted file drops out", func(t *testing.T) { + dir, idx := toolWriteTestIndexer(t, 3) + requireOneMatch(t, idx.GrepText("func Other", 10), "other.go", 3) + require.NoError(t, os.Remove(filepath.Join(dir, "other.go"))) + idx.noteToolWrite("other.go") + require.Empty(t, idx.GrepText("func Other", 10)) + require.Empty(t, idx.toolWrites.snapshot(), "a missing file's note retires") + }) + + t.Run("a note made again while an older one is applied survives it", func(t *testing.T) { + var set toolWriteSet + set.note("main.go") + applied := set.snapshot() + set.note("main.go") + set.retire(applied, func(string) bool { return true }) + require.Contains(t, set.snapshot(), "main.go") + }) +} diff --git a/internal/indexer/incremental_batch.go b/internal/indexer/incremental_batch.go index 163502030..20032aa60 100644 --- a/internal/indexer/incremental_batch.go +++ b/internal/indexer/incremental_batch.go @@ -230,6 +230,12 @@ func (idx *Indexer) reindexIncrementalStalePass( var plan DerivedInvalidationPlan var reparsed, failed, versionChanged []string for start := 0; start < len(files); { + if idx.warmupWriteBoundary != nil { + if err := idx.warmupWriteBoundary(); err != nil { + idx.contractRestatementErr = err + return plan, reparsed, append(failed, files[start:]...), versionChanged + } + } end := min(start+incrementalBatchFiles, len(files)) consumed, chunkPlan, chunkReparsed, chunkFailed, chunkVersionChanged := idx.reindexIncrementalChunk( files[start:end], markerBatch, acceptPreparedSnapshot, diff --git a/internal/indexer/incremental_watcher_batch.go b/internal/indexer/incremental_watcher_batch.go index 1d7e2f425..b54c48c83 100644 --- a/internal/indexer/incremental_watcher_batch.go +++ b/internal/indexer/incremental_watcher_batch.go @@ -412,6 +412,9 @@ func (idx *Indexer) incrementalWatcherPaths(root string, paths []string, mode in } else { idx.runIncrementalWatcherSemantic(result.DerivedInvalidation.Files) } + if mode.onRelations != nil && len(result.FailedFiles) == 0 { + mode.onRelations() + } idx.observeIncrementalCatchup("derived", result.DerivedInvalidation.Files) derived := mode.publication.beginDerivedPatchLap() idx.runStandaloneIncrementalDerivedPassesWithPrior(result.DerivedInvalidation, result.capabilityPrior) @@ -480,7 +483,15 @@ func vanishedReceiptNames(g graph.Store, receipt *graph.MutationReceipt) []strin // reindexExplicitMutationPaths preserves forced point-read semantics for the // complete admitted set, with one lane-owned resolver/derived tail. -func (w *Watcher) reindexExplicitMutationPaths(paths []string) (*IndexResult, DerivedFanoutCompleteness, error) { +func (w *Watcher) reindexExplicitMutationPaths(paths []string, admitted ...map[string]uint64) (*IndexResult, DerivedFanoutCompleteness, error) { + frontier := w.mutationTierFrontier(paths) + if len(admitted) > 0 { + frontier = admitted[0] + } + return w.reindexMutationPaths(paths, true, frontier) +} + +func (w *Watcher) reindexMutationPaths(paths []string, force bool, frontier map[string]uint64) (*IndexResult, DerivedFanoutCompleteness, error) { var result *IndexResult var fanout DerivedFanoutCompleteness ctx, cancel := w.mutationLaneContext() @@ -492,11 +503,13 @@ func (w *Watcher) reindexExplicitMutationPaths(paths []string) (*IndexResult, De } observation := beginDerivedFanoutObservation(idx) defer observation.close() - mode := incrementalPathMode{detectDeletions: true, forceExplicitFiles: true} + if !force && idx.deferGlobalPasses.Load() { + w.markDeferredMutationTiers(frontier) + } + mode := incrementalPathMode{detectDeletions: true, forceExplicitFiles: force, + onRelations: func() { w.markRelationsFrontier(frontier) }} var err error - if w.explicitReindexRaw != nil { - result, err = w.explicitReindexRaw(paths) - } else if owner := idx.repositoryMutationOwner; owner != nil && idx.repoPrefix != "" { + if owner := idx.repositoryMutationOwner; owner != nil && idx.repoPrefix != "" { result, err = owner.incrementalReindexRepoRawMode(idx.repoPrefix, paths, mode) } else { result, err = idx.incrementalWatcherPaths(idx.rootPath, paths, mode) @@ -510,14 +523,24 @@ func (w *Watcher) reindexExplicitMutationPaths(paths []string) (*IndexResult, De return result, fanout, err } -func (w *Watcher) reindexStormPaths(paths []string) (*IndexResult, error) { - if w.batchReindex != nil { - return w.batchReindex(paths) +func (w *Watcher) reindexStormPaths(paths []string, admitted ...map[string]uint64) (*IndexResult, error) { + result, _, err := w.reindexStormPathsObserved(paths, admitted...) + return result, err +} + +func (w *Watcher) reindexStormPathsObserved(paths []string, admitted ...map[string]uint64) (*IndexResult, DerivedFanoutCompleteness, error) { + if w.batchReindex != nil && (w.indexer == nil || w.indexer.repositoryMutationOwner == nil) { + result, err := w.batchReindex(paths) + return result, DerivedFanoutCompleteness{}, err } if w.indexer == nil || w.indexer.rootPath == "" { - return nil, fmt.Errorf("watcher: index root is not initialized") + return nil, DerivedFanoutCompleteness{}, fmt.Errorf("watcher: index root is not initialized") + } + frontier := w.mutationTierFrontier(paths) + if len(admitted) > 0 { + frontier = admitted[0] } - return w.indexer.IncrementalReindexPaths(w.indexer.rootPath, paths) + return w.reindexMutationPaths(paths, false, frontier) } func (gw *GitWatcher) reindexChangedPaths(paths []string) (*IndexResult, error) { diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index d4ce2cb8d..14c3f634b 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -201,6 +201,9 @@ type Indexer struct { // construction. Reading graph before repository-lane admission would race // a cold index temporarily replacing it with an in-memory shadow. primaryWatcherWriteIntent func() func() + // warmupWriteBoundary belongs only to a not-yet-published restored indexer. + // ReconcileRepoCtx clears it before publication; edits never inherit waits. + warmupWriteBoundary func() error // Contract followups use accepted selected-view evidence exclusively. These // fields are installed only on the private analysis Indexer, never the core // publisher; a missing source callback is an incomplete analysis input. @@ -324,6 +327,10 @@ type Indexer struct { // the whole corpus, which is what an index-generation bump used to // force after a single edit. trigramDirty map[string]struct{} + // toolWrites are the paths tools wrote that the text index has not read + // yet (grep.go). It has its own lock: noting a write must not wait out a + // trigram build, which holds trigramMu for a whole corpus read. + toolWrites toolWriteSet // trigramBudgetOverride scopes the idle/LRU eviction budget to one // test rather than the process-wide default. Nil in production. trigramBudgetOverride *trigramBudget @@ -7092,6 +7099,7 @@ type incrementalPathMode struct { // mutable Indexer tracing state is installed by this per-call observation. publication *PublicationStamps readRepair *ReadRepairObservation + onRelations func() // resolver and file publication complete, before derived work } // indexedFilesAbsentFromDisk returns the repo-relative keys of files the graph diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index 37cd1b9dc..3df9da2d9 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -24,6 +24,7 @@ import ( "github.com/zzet/gortex/internal/embedding" "github.com/zzet/gortex/internal/entrypoints" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" "github.com/zzet/gortex/internal/indexer/source" "github.com/zzet/gortex/internal/parser" "github.com/zzet/gortex/internal/pathkey" @@ -86,6 +87,7 @@ type repositoryUntrackState struct { // MultiIndexer orchestrates indexing across multiple repositories. type MultiIndexer struct { graph graph.Store + relationsPending atomic.Bool // restart batch has not published its shared resolver tail contractCoreRuntime atomic.Pointer[ContractCoreRuntimeHooks] // contractSources caches the accepted bytes each journal fingerprinted, // by source fingerprint, for contract analysis reads. @@ -557,6 +559,7 @@ func (mi *MultiIndexer) BeginBatch() { // EndBatch; call RunDeferredPassesAllResult between the parallel parse and // EndBatch to run the deferred per-repo passes serially. func (mi *MultiIndexer) BeginParallelBatch() { + mi.relationsPending.Store(true) mi.batchMutationGate.Lock() defer mi.batchMutationGate.Unlock() @@ -2639,6 +2642,9 @@ func (mi *MultiIndexer) incrementalReindexRepoRawMode(repoPrefix string, paths [ mi.mu.Unlock() mtimes.end() + if mode.onRelations != nil && len(result.FailedFiles) == 0 { + mode.onRelations() + } idx.observeIncrementalCatchup("derived", result.DerivedInvalidation.Files) derived := mode.publication.beginDerivedPatchLap() mi.runIncrementalDerivedPassesWithPriorTopologyHeld(context.Background(), map[string]DerivedInvalidationPlan{ @@ -3192,6 +3198,14 @@ func (mi *MultiIndexer) ReconcileRepoCtx(ctx context.Context, entry config.RepoE idx.SetProjectID(resolveProjectID(&entryCopy, cfg, prefix)) idx.SetRootPath(absPath) idx.SetFileMtimes(priorMtimes) + if store, ok := mi.graph.(*store_sqlite.Store); ok { + idx.warmupWriteBoundary = func() error { return store.AwaitWarmupWAL(ctx) } + defer func() { + if idx.warmupWriteBoundary != nil { + idx.warmupWriteBoundary = nil + } + }() + } restoreTiming.complete(nil) var result *IndexResult @@ -3333,6 +3347,7 @@ func (mi *MultiIndexer) ReconcileRepoCtx(ctx context.Context, entry config.RepoE result.RepoPrefix = prefix fileMtimes := idx.publishFileMtimes() + idx.warmupWriteBoundary = nil // Restore the winner's config after any competing prefix resolution. _, publish := mi.configMgr.PrepareWorkspaceConfig(prefix, absPath, nil) isWorktree := ResolveWorktree(absPath).IsWorktree diff --git a/internal/indexer/multi_reconcile_sqlite_test.go b/internal/indexer/multi_reconcile_sqlite_test.go index 6594ab352..0215191cb 100644 --- a/internal/indexer/multi_reconcile_sqlite_test.go +++ b/internal/indexer/multi_reconcile_sqlite_test.go @@ -68,12 +68,14 @@ func TestReconcileRepoCtx_Sqlite_FullRetrackFlag(t *testing.T) { // so ReconcileRepoCtx must report neither flag. meta2 := mi2.GetMetadata("repo") require.NotNil(t, meta2) + require.Nil(t, mi2.GetIndexer("repo").warmupWriteBoundary, "published edit indexer retained a startup wait") unchangedMtimes := mi2.FileMtimes("repo") mi3 := NewMultiIndexer(graph.Store(s), newTestRegistry(), search.NewNull(), cm, zap.NewNop()) result2, err := mi3.ReconcileRepoCtx(context.Background(), config.RepoEntry{Path: repoPath, Name: "repo"}, unchangedMtimes) require.NoError(t, err) require.NotNil(t, result2) + require.Nil(t, mi3.GetIndexer("repo").warmupWriteBoundary, "clean-census publication retained a startup wait") assert.False(t, result2.FullRetrack, "an unchanged disk-backed repo must not be reported as a full re-track") diff --git a/internal/indexer/multi_watcher.go b/internal/indexer/multi_watcher.go index 0a347d395..676c6a70a 100644 --- a/internal/indexer/multi_watcher.go +++ b/internal/indexer/multi_watcher.go @@ -133,6 +133,7 @@ type MultiWatcher struct { // atomically with an ownership-epoch dispatch admission. callbackMu sync.Mutex symbolChangeCb SymbolChangeCallback + sourceEventCb func(string) // degradedCb is fanned out to every per-repo Watcher (and re-applied at // AddRepo) so a watcher entering a degraded state — inotify / FD // exhaustion — pushes a single health notice through the daemon. @@ -233,6 +234,9 @@ func (mw *MultiWatcher) createWatcher(prefix string, cfg config.WatchConfig) err if err != nil { return fmt.Errorf("creating watcher for %s: %w", prefix, err) } + // Resolve the current callback at delivery time: replacements and future + // watchers share registration without a stale fanout snapshot. + w.OnSourceEvent(mw.notifySourceEvent) if mw.configManagedExcludes { mw.configureManagedExcludes(prefix, w) } @@ -248,12 +252,6 @@ func (mw *MultiWatcher) createWatcher(prefix string, cfg config.WatchConfig) err w.discoverReindex = func(paths []string) (*IndexResult, error) { return mw.multi.incrementalDiscoverRepo(prefix, paths) } - w.explicitReindexRaw = func(paths []string) (*IndexResult, error) { - return mw.multi.incrementalReindexRepoRawMode(prefix, paths, incrementalPathMode{detectDeletions: true, forceExplicitFiles: true}) - } - w.pointReindexRaw = func(filePath string, stamps *PublicationStamps) (*IndexResult, error) { - return mw.multi.incrementalPointRepoRawObserved(prefix, filePath, stamps) - } mw.watchers[prefix] = w return nil @@ -1027,6 +1025,23 @@ func (mw *MultiWatcher) HistorySince(since time.Time) []GraphChangeEvent { return out } +// OnSourceEvent replaces the synchronous source-path callback for every +// current and future per-repo watcher. The callback must return promptly. +func (mw *MultiWatcher) OnSourceEvent(cb func(string)) { + mw.callbackMu.Lock() + mw.sourceEventCb = cb + mw.callbackMu.Unlock() +} + +func (mw *MultiWatcher) notifySourceEvent(path string) { + mw.callbackMu.Lock() + cb := mw.sourceEventCb + mw.callbackMu.Unlock() + if cb != nil { + cb(path) + } +} + // OnSymbolChange registers the callback against every current per-repo // Watcher and stores it so AddRepo applies it to future watchers too. // Replaces any previously registered callback (matches Watcher.OnSymbolChange diff --git a/internal/indexer/multi_watcher_visibility_test.go b/internal/indexer/multi_watcher_visibility_test.go index bb8403d42..ee61feb6d 100644 --- a/internal/indexer/multi_watcher_visibility_test.go +++ b/internal/indexer/multi_watcher_visibility_test.go @@ -14,6 +14,7 @@ import ( // every configured repo as watched and the graph aged behind a green status. func TestMultiWatcherReportsRepoThatNeverStarted(t *testing.T) { mw, _, repoADir, _ := setupMultiWatcherTest(t) + require.False(t, mw.TierAuthority("repo-a"), "configured is not yet watching") // Repo A's root disappears before the watchers come up — a checkout // removed under a running daemon. @@ -24,6 +25,9 @@ func TestMultiWatcherReportsRepoThatNeverStarted(t *testing.T) { live, configured := mw.WatchedRepos() require.Equal(t, 2, configured) require.Equal(t, 1, live, "only repo-b can be watched once repo-a's root is gone") + require.False(t, mw.TierAuthority("repo-a"), "a failed startup cannot certify an empty tier set") + require.True(t, mw.TierAuthority("repo-b")) + require.False(t, mw.TierAuthority("unknown-repo")) reason := mw.DegradedReason() require.NotEmpty(t, reason, "an unwatched repository must not read as healthy") @@ -37,10 +41,15 @@ func TestMultiWatcherHealthyWhenAllReposStart(t *testing.T) { mw, _, _, _ := setupMultiWatcherTest(t) require.NoError(t, mw.Start()) - t.Cleanup(func() { _ = mw.Stop() }) + t.Cleanup(func() { + _ = mw.Stop() + require.False(t, mw.TierAuthority("repo-a"), "a stopped watcher cannot certify a tier") + }) live, configured := mw.WatchedRepos() require.Equal(t, configured, live) require.Equal(t, 2, live) require.Empty(t, mw.DegradedReason()) + require.True(t, mw.TierAuthority("repo-a")) + require.True(t, mw.TierAuthority("repo-b")) } diff --git a/internal/indexer/mutation_tier_boundaries_test.go b/internal/indexer/mutation_tier_boundaries_test.go new file mode 100644 index 000000000..a580be291 --- /dev/null +++ b/internal/indexer/mutation_tier_boundaries_test.go @@ -0,0 +1,109 @@ +package indexer + +import ( + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/semantic" +) + +func TestStandaloneRelationsWaitForSemanticCatchup(t *testing.T) { + t.Setenv("GORTEX_ENRICH_MIN_NODES", "0") + dir, watcher := toolAdmissionFixture(t) + idx := watcher.indexer + path := filepath.Join(dir, "main.go") + manager := semantic.NewManager(semantic.Config{Enabled: true, EnrichOnWatch: true}, zap.NewNop()) + manager.RegisterProvider(&retainingRepoProvider{retained: make(map[string]struct{}), leased: make(map[string]struct{})}) + idx.SetSemanticManager(manager) + writeTestFile(t, path, "package main\nfunc Changed() {}\n") + entered, release := make(chan MutationTierState, 1), make(chan struct{}) + unblock := sync.OnceFunc(func() { close(release) }) + defer unblock() + idx.incrementalCatchupHook = func(kind string, _ []string) { + if kind == "semantic" { + entered <- watcher.TierState(path) + <-release + } + } + ticket, err := watcher.EnqueueToolMutation(t.Context(), path) + require.NoError(t, err) + select { + case state := <-entered: + require.True(t, state.RelationsAt.IsZero(), "semantic references have not published yet") + case <-time.After(2 * time.Second): + t.Fatal("the real semantic pipeline did not reach its hook") + } + unblock() + select { + case result := <-ticket.Done: + require.NoError(t, result.Err) + require.False(t, ticket.TierState().RelationsAt.IsZero()) + case <-time.After(2 * time.Second): + t.Fatal("semantic completion did not release the ticket") + } +} + +func TestMixedBatchDoesNotPublishFailedRelationsBeforeDerived(t *testing.T) { + dir, watcher := toolAdmissionFixture(t) + idx := watcher.indexer + good, bad := filepath.Join(dir, "main.go"), filepath.Join(dir, "bad.go") + writeTestFile(t, bad, "package main\nfunc Bad() {}\n") + require.NoError(t, idx.IndexFile(bad)) + idx.registry.Register(contractFailingGoExtractor{GoExtractor: languages.NewGoExtractor()}) + writeTestFile(t, good, "package main\nfunc GoodChanged() {}\n") + writeTestFile(t, bad, "package main\n// EXTRACT_FAIL\nfunc BadChanged() {}\n") + entered, release := make(chan MutationTierState, 1), make(chan struct{}) + unblock := sync.OnceFunc(func() { close(release) }) + defer unblock() + idx.incrementalCatchupHook = func(kind string, _ []string) { + if kind == "derived" { + entered <- watcher.TierState(bad) + <-release + } + } + tickets, err := watcher.EnqueueFileMutations(t.Context(), []string{good, bad}) + require.NoError(t, err) + select { + case state := <-entered: + require.True(t, state.RelationsAt.IsZero(), "a failed parse cannot certify its old relations") + case <-time.After(2 * time.Second): + t.Fatal("mixed-success batch did not reach derived work") + } + unblock() + for path, ticket := range tickets { + select { + case result := <-ticket.Done: + if path == bad { + require.Error(t, result.Err) + } else { + require.NoError(t, result.Err) + } + case <-time.After(2 * time.Second): + t.Fatal("batch ticket did not finish") + } + } +} + +func TestDeferredNativeBatchDoesNotCertifyUnrunTiers(t *testing.T) { + dir, watcher := toolAdmissionFixture(t) + idx := watcher.indexer + idx.SetDeferGlobalPasses(true) + path := filepath.Join(dir, "main.go") + writeTestFile(t, path, "package main\nfunc DeferredChanged() {}\n") + watcher.config.StormQuietPeriodMs = int(time.Hour / time.Millisecond) + watcher.recordInStormEvent(path, ChangeModified, true) + frontier := watcher.mutationTierFrontier([]string{path}) + result, fanout, err := watcher.reindexMutationPaths([]string{path}, false, frontier) + require.NoError(t, err) + require.NotEmpty(t, idx.graph.FindNodesByName("DeferredChanged"), "file graph actually published") + watcher.completeStormMutationWaiters(frontier, result, nil, fanout) + state := watcher.TierState(path) + require.True(t, state.Partial, "deferred passes must remain visibly incomplete") + require.True(t, state.DerivedAt.IsZero(), "a skipped derived pass has no publication stamp") +} diff --git a/internal/indexer/mutation_tiers.go b/internal/indexer/mutation_tiers.go new file mode 100644 index 000000000..508f379ed --- /dev/null +++ b/internal/indexer/mutation_tiers.go @@ -0,0 +1,252 @@ +package indexer + +import ( + "path/filepath" + "time" +) + +// MutationTierState is the publication evidence for one admitted path. An +// absent timestamp is unobserved, rather than evidence of a fresh tier. +type MutationTierState struct { + Generation uint64 `json:"generation"` + AckAt time.Time `json:"ack_at"` + FileGraphAt time.Time `json:"file_graph_at,omitempty"` + RelationsAt time.Time `json:"relations_at,omitempty"` + DerivedAt time.Time `json:"derived_at,omitempty"` + Failed bool `json:"failed,omitempty"` + Partial bool `json:"partial,omitempty"` + failedAt time.Time +} + +func newMutationTierTicket(path string, generation uint64, done <-chan MutationResult) *MutationTicket { + return &MutationTicket{Path: path, Generation: generation, Done: done, + tiers: MutationTierState{Generation: generation, AckAt: time.Now()}} +} + +func (w *Watcher) markRelations(path string, generation uint64) { + w.mu.Lock() + defer w.mu.Unlock() + for ordinal, waiter := range w.mutationWaiters[path] { + if ordinal <= generation && waiter.ticket != nil { + waiter.ticket.tierMu.Lock() + stampRelations(&waiter.ticket.tiers, time.Now()) + waiter.ticket.tierMu.Unlock() + } + } +} + +func stampRelations(state *MutationTierState, at time.Time) { + if state.FileGraphAt.IsZero() { + state.FileGraphAt = at + } + if state.RelationsAt.IsZero() { + state.RelationsAt = at + } +} + +func (w *Watcher) mutationTierFrontier(paths []string) map[string]uint64 { + w.mu.Lock() + defer w.mu.Unlock() + frontier := make(map[string]uint64, len(paths)) + for _, path := range paths { + frontier[path] = w.pendingGeneration[path] + } + return frontier +} + +func (w *Watcher) markRelationsFrontier(frontier map[string]uint64) { + for path, generation := range frontier { + w.markRelations(path, generation) + } +} + +// finishMutationTierLocked runs before removing a waiter or delivering Done. +// Successful paths retain no watcher history; the caller's ticket owns stamps. +func (w *Watcher) finishMutationTierLocked(path string, ticket *MutationTicket, err error, fanout DerivedFanoutCompleteness) { + if ticket == nil { + return + } + ticket.tierMu.Lock() + defer ticket.tierMu.Unlock() + state := &ticket.tiers + if err != nil { + state.Failed = true + state.failedAt = time.Now() + if w.failedPaths == nil { + w.failedPaths = make(map[string]MutationTierState) + } + if prior := w.failedPaths[path]; prior.Generation <= state.Generation { + w.failedPaths[path] = *state + } + return + } + at := time.Now() + deferred := state.Partial + if !deferred { + stampRelations(state, at) // identity no-ops and test executors have no tail + state.DerivedAt = at + } else if state.FileGraphAt.IsZero() { + state.FileGraphAt = at + } + state.Partial = deferred || fanout.Observed && !fanout.Complete + if state.Partial { + state.failedAt = at + if w.failedPaths == nil { + w.failedPaths = make(map[string]MutationTierState) + } + if prior := w.failedPaths[path]; prior.Generation <= state.Generation { + w.failedPaths[path] = *state + } + } else if prior := w.failedPaths[path]; prior.Generation <= state.Generation { + delete(w.failedPaths, path) + } +} + +// TierPaths returns outstanding paths and unrepaired per-path failures. +// Receipt expiry is not content evidence; only a successful patch retires gaps. +// It shares the waiter's existing path index; idle reads allocate no map. +func (w *Watcher) TierPaths() map[string]MutationTierState { + w.mu.Lock() + defer w.mu.Unlock() + var states map[string]MutationTierState + put := func(path string, state MutationTierState) { + if states == nil { + states = make(map[string]MutationTierState) + } + if states[path].Generation <= state.Generation { + states[path] = state + } + } + for path, state := range w.failedPaths { + put(path, state) + } + for path, byGeneration := range w.mutationWaiters { + for _, waiter := range byGeneration { + if waiter.ticket != nil { + put(path, waiter.ticket.TierState()) + } + } + } + return states +} + +func (w *Watcher) TierState(path string) MutationTierState { + w.mu.Lock() + defer w.mu.Unlock() + path = filepath.Clean(path) + state := w.failedPaths[path] + for generation, waiter := range w.mutationWaiters[path] { + if generation >= state.Generation && waiter.ticket != nil { + state = waiter.ticket.TierState() + } + } + return state +} + +// CaptureTierRepair binds a successful explicit single-file reparse to the +// failure it observed before starting. The caller invokes it only after proof +// that this exact file was reparsed successfully. Bounded derived incompleteness +// is separate evidence and cannot be repaired by a file parse alone. +func (w *Watcher) CaptureTierRepair(path string) func() { + path = filepath.Clean(path) + w.mu.Lock() + observed := w.failedPaths[path] + w.mu.Unlock() + if !observed.Failed || observed.Partial { + return nil + } + return func() { + w.mu.Lock() + defer w.mu.Unlock() + if w.failedPaths[path] == observed { + delete(w.failedPaths, path) + } + } +} + +func (mw *MultiWatcher) CaptureTierRepair(path string) func() { + mw.mu.Lock() + _, prefix := mw.multi.IndexerForFile(path) + watcher := mw.watchers[prefix] + mw.mu.Unlock() + if watcher != nil { + // Capture this exact owner now. Looking it up after the reparse could + // clear a replacement watcher's failure that the pass never observed. + return watcher.CaptureTierRepair(path) + } + return nil +} + +func (mw *MultiWatcher) TierPaths() map[string]MutationTierState { + mw.mu.Lock() + watchers := make([]*Watcher, 0, len(mw.watchers)) + for _, watcher := range mw.watchers { + watchers = append(watchers, watcher) + } + mw.mu.Unlock() + var states map[string]MutationTierState + for _, watcher := range watchers { + for path, state := range watcher.TierPaths() { + if states == nil { + states = make(map[string]MutationTierState) + } + states[path] = state + } + } + return states +} + +// TierAuthority reports whether this canonical repository has a live, healthy +// watcher. An empty outstanding-path set alone cannot establish that fact. +func (mw *MultiWatcher) TierAuthority(prefix string) bool { + if mw == nil { + return false + } + mw.mu.Lock() + defer mw.mu.Unlock() + w := mw.watchers[prefix] + return !mw.stopped && mw.started[prefix] && w != nil && w.DegradedReason() == "" +} + +func (mw *MultiWatcher) TierState(path string) MutationTierState { + mw.mu.Lock() + _, prefix := mw.multi.IndexerForFile(path) + watcher := mw.watchers[prefix] + mw.mu.Unlock() + if watcher != nil { + return watcher.TierState(path) + } + return MutationTierState{} +} + +// RelationsPending reports a restarted workspace's shared resolver boundary. +// The early serving boundary can precede cross-repository reference binding. +func (mi *MultiIndexer) RelationsPending() bool { return mi.relationsPending.Load() } + +// TierState reads a ticket's live stamps; the ticket survives waiter removal. +func (t *MutationTicket) TierState() MutationTierState { + if t == nil { + return MutationTierState{} + } + t.tierMu.RLock() + defer t.tierMu.RUnlock() + return t.tiers +} + +// CompleteStartupRelations is called only after successful warmup resolution. +// ResetBatch also serves cancelled warmup and cannot certify this boundary. +func (mi *MultiIndexer) CompleteStartupRelations() { mi.relationsPending.Store(false) } + +func (w *Watcher) markDeferredMutationTiers(frontier map[string]uint64) { + w.mu.Lock() + defer w.mu.Unlock() + for path, generation := range frontier { + for ordinal, waiter := range w.mutationWaiters[path] { + if ordinal <= generation && waiter.ticket != nil { + waiter.ticket.tierMu.Lock() + waiter.ticket.tiers.Partial = true + waiter.ticket.tierMu.Unlock() + } + } + } +} diff --git a/internal/indexer/mutation_tiers_test.go b/internal/indexer/mutation_tiers_test.go new file mode 100644 index 000000000..00c5d5361 --- /dev/null +++ b/internal/indexer/mutation_tiers_test.go @@ -0,0 +1,141 @@ +package indexer + +import ( + "errors" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestReceiptTierTimestampsAreOrdered(t *testing.T) { + dir, idx, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Before() {}\n") + path := filepath.Join(dir, "main.go") + writeTestFile(t, path, "package main\nfunc After() {}\n") + derived, release := make(chan struct{}), make(chan struct{}) + defer close(release) + idx.incrementalCatchupHook = func(kind string, _ []string) { + if kind == "derived" { + close(derived) + <-release + } + } + ticket, err := watcher.EnqueueToolMutation(t.Context(), path) + require.NoError(t, err) + select { + case <-derived: + case <-time.After(2 * time.Second): + t.Fatal("the real patch did not reach its derived pass") + } + tiers := ticket.TierState() + require.False(t, tiers.AckAt.IsZero(), "admission must stamp the live ticket") + require.True(t, tiers.AckAt.Before(tiers.FileGraphAt)) + require.False(t, tiers.RelationsAt.Before(tiers.FileGraphAt)) + require.True(t, tiers.DerivedAt.IsZero(), "relations must publish before blocked derived work") +} + +func TestNativeOnlyStormHasFreshnessEvidence(t *testing.T) { + dir, watcher := toolAdmissionFixture(t) + path := filepath.Join(dir, "main.go") + watcher.config.StormQuietPeriodMs = int(time.Hour / time.Millisecond) + writeTestFile(t, path, "package main\nfunc After() {}\n") + watcher.recordInStormEvent(path, ChangeModified, true) + state, found := watcher.TierPaths()[path] + require.True(t, found, "a native-only storm edit must be visible before its drain") + require.False(t, state.AckAt.IsZero()) + require.True(t, state.RelationsAt.IsZero()) + watcher.completeStormMutationWaiters(map[string]uint64{path: state.Generation}, nil, errors.New("patch failed")) + require.True(t, watcher.TierState(path).Failed, "the native path must retain its terminal failure") + + repair, err := watcher.EnqueueToolMutation(t.Context(), path) + require.NoError(t, err) + select { + case result := <-repair.Done: + require.NoError(t, result.Err) + case <-time.After(2 * time.Second): + t.Fatal("repair did not finish") + } + require.NotContains(t, watcher.TierPaths(), path, "successful newer content repairs the failed path") +} + +func TestExpiredReceiptCannotCertifyUnrepairedPath(t *testing.T) { + for _, partial := range []bool{false, true} { + t.Run(map[bool]string{false: "failed", true: "partial"}[partial], func(t *testing.T) { + dir, _, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Before() {}\n") + path := filepath.Join(dir, "main.go") + watcher.mu.Lock() + watcher.failedPaths = map[string]MutationTierState{path: { + Generation: 7, Failed: !partial, Partial: partial, failedAt: time.Now().Add(-11 * time.Minute), + }} + watcher.mu.Unlock() + state := watcher.TierState(path) + require.True(t, state.Failed || state.Partial, "receipt expiry is not content publication") + require.Contains(t, watcher.TierPaths(), path) + }) + } +} + +func TestIdentityPatchPublishesTicketTiers(t *testing.T) { + dir, _, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Before() {}\n") + ticket, err := watcher.EnqueueToolMutation(t.Context(), filepath.Join(dir, "main.go")) + require.NoError(t, err) + select { + case result := <-ticket.Done: + require.NoError(t, result.Err) + case <-time.After(2 * time.Second): + t.Fatal("unchanged mutation did not finish") + } + tiers := ticket.TierState() + require.False(t, tiers.DerivedAt.IsZero(), "a successful identity no-op still certifies the admitted bytes") + require.False(t, tiers.Failed) +} + +func TestExplicitTierRepairIsBoundToFailedGeneration(t *testing.T) { + for _, scenario := range []string{"same_failure", "newer_failure", "partial", "newer_pending"} { + t.Run(scenario, func(t *testing.T) { + dir, _, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Main() {}\n") + path := filepath.Join(dir, "main.go") + failure := MutationTierState{Generation: 7, Failed: true, failedAt: time.Now()} + if scenario == "partial" { + failure.Failed, failure.Partial = false, true + } + watcher.failedPaths = map[string]MutationTierState{path: failure} + repair := watcher.CaptureTierRepair(path) + if scenario == "newer_failure" { + failure.Generation++ + watcher.failedPaths[path] = failure + } + if scenario == "newer_pending" { + ticket := newMutationTierTicket(path, 8, nil) + watcher.mutationWaiters = map[string]map[uint64]mutationWaiter{ + path: {8: {ticket: ticket}}, + } + } + if repair != nil { + repair() + } + switch scenario { + case "same_failure": + require.NotContains(t, watcher.TierPaths(), path, "an explicit successful reparse repairs the captured failure") + case "newer_failure": + require.Equal(t, failure, watcher.TierState(path), "the pass did not observe this later failure") + case "partial": + require.True(t, watcher.TierState(path).Partial, "a file parse alone cannot certify bounded derived fanout") + case "newer_pending": + require.Equal(t, uint64(8), watcher.TierState(path).Generation) + require.NotContains(t, watcher.failedPaths, path) + } + }) + } +} + +func TestStartupRelationsRemainPendingAfterCancelledBatchReset(t *testing.T) { + mi := &MultiIndexer{} + mi.BeginParallelBatch() + require.True(t, mi.RelationsPending()) + mi.ResetBatch() // also used when warmup fails or is cancelled + require.True(t, mi.RelationsPending(), "an aborted batch cannot certify startup relations") + mi.CompleteStartupRelations() + require.False(t, mi.RelationsPending()) +} diff --git a/internal/indexer/parse_errors.go b/internal/indexer/parse_errors.go index f3936c025..a0d6e7820 100644 --- a/internal/indexer/parse_errors.go +++ b/internal/indexer/parse_errors.go @@ -15,7 +15,14 @@ import ( // done) get no stamp — absence of the meta key means "unknown", not // "clean". func stampParseErrors(result *parser.ExtractionResult) { - if result == nil || result.Tree == nil { + if result == nil { + return + } + if result.ParseChecked && result.ParseErrors >= 0 { + stampParseErrorCount(result.Nodes, result.ParseErrors) + return + } + if result.Tree == nil { return } if !result.Tree.HasParseErrors() { diff --git a/internal/indexer/project_source.go b/internal/indexer/project_source.go new file mode 100644 index 000000000..02438a348 --- /dev/null +++ b/internal/indexer/project_source.go @@ -0,0 +1,207 @@ +package indexer + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" +) + +// projectionLanguages are the languages whose single-file projection equals a +// full index of the same bytes: node IDs, kinds and line ranges. The golden +// parity test pins the list; a language that drifts leaves it. +var projectionLanguages = map[string]bool{ + "go": true, + "typescript": true, + "python": true, + "c": true, + "java": true, + "rust": true, +} + +// projectionBuildDeadline bounds one build, including the wait for a parse +// slot. A projection that cannot be built in time is not worth an edit's ack. +const projectionBuildDeadline = 200 * time.Millisecond + +// projectionParseAdmission is reserved for projection builds, apart from the +// shared budget bulk indexing fills, so an edit's projection never queues +// behind a cold index. It admits two maximum-size sources at once. +var projectionParseAdmission = newParseAdmissionBudget(2 * maxAdHocExtractSourceBytes) + +// Reasons a source is not projected. Callers fall back to the store. +var ( + ErrProjectionLanguage = errors.New("projection: language not projected") + ErrProjectionTransform = errors.New("projection: source transform does not preserve coordinates") + ErrProjectionTooLarge = errors.New("projection: source over the size limit") + ErrProjectionNoSlot = errors.New("projection: no parse slot before the deadline") + ErrProjectionDeadline = errors.New("projection: build past the deadline") + ErrProjectionRefused = errors.New("projection: extraction refused") +) + +// Projection is the graph content one file contributes to the index, built +// from caller-owned bytes without reading the store: the file's nodes and the +// edges they record, stamped as the indexer would stamp them. Edges keep their +// unresolved targets; resolution is cross-file work the projection skips. +type Projection struct { + GraphPath string + Language string + // ContentHash is computed over the transformed bytes with the same + // function as FileMetaRow.ContentHash, so the two are equal once the + // index holds these bytes. + ContentHash string + Nodes []*graph.Node + Edges []*graph.Edge + ParseErrors int + ParseChecked bool // zero errors are trustworthy only with the explicit full-parse proof + BuildTime time.Duration +} + +// ProjectSource builds the projection of src, the bytes a tool just wrote to +// absPath. It runs the indexer's own transforms, extraction and coverage +// passes, so the nodes equal what a full index stores for the file. Sources +// the indexer would treat differently from a plain parse, and builds that +// cannot finish within projectionBuildDeadline, return one of the +// ErrProjection errors. +func (idx *Indexer) ProjectSource(ctx context.Context, absPath string, src []byte) (*Projection, error) { + started := time.Now() + if idx == nil || idx.registry == nil { + return nil, fmt.Errorf("indexer source projection is unavailable") + } + limit := int64(maxAdHocExtractSourceBytes) + if idx.config.MaxFileSize > 0 && idx.config.MaxFileSize < limit { + limit = idx.config.MaxFileSize + } + if int64(len(src)) > limit { + return nil, ErrProjectionTooLarge + } + language, ok := idx.effectiveLanguage(absPath, src) + if !ok || !projectionLanguages[language] { + return nil, ErrProjectionLanguage + } + extractor, ok := idx.registry.GetByLanguage(language) + if !ok { + return nil, ErrProjectionLanguage + } + if _, err := idx.transforms.prepareCoordinateStable(absPath, src); err != nil { + return nil, fmt.Errorf("%w: %v", ErrProjectionTransform, err) + } + + ctx, cancel := context.WithTimeout(ctx, projectionBuildDeadline) + defer cancel() + relPath := idx.relKey(absPath) + // No command transform matches this path, so run only applies the + // built-in passes the index applies; hashing its output reproduces + // the stored content hash. + transformed := idx.transforms.run(relPath, src) + rawLease, err := acquireParseAdmission(ctx, int64(len(transformed)), 0, nil, projectionParseAdmission) + if err != nil { + return nil, ErrProjectionNoSlot + } + // A parse cannot be cancelled, so a build still running at the deadline + // is left to finish in the background. It keeps its lease until then, + // which bounds how many abandoned builds can pile up. + type extraction struct { + result *parser.ExtractionResult + err error + } + done := make(chan extraction, 1) + go func() { + defer rawLease.Release() + result, err := idx.extractSourceWith( + ctx, rawLease, nil, + absPath, relPath, language, extractor, transformed, + ) + done <- extraction{result, err} + }() + var built extraction + select { + case built = <-done: + case <-ctx.Done(): + go func() { + if late := <-done; late.result != nil { + late.result.ReleaseTree() + } + }() + return nil, ErrProjectionDeadline + } + switch { + case built.err != nil && ctx.Err() != nil: + return nil, ErrProjectionDeadline + case built.err != nil: + return nil, fmt.Errorf("%w: %v", ErrProjectionRefused, built.err) + } + result := built.result + defer result.ReleaseTree() + + if !extractionDispositionFor(result).omitSecondarySourceScans() { + idx.applyCoverageDomains(relPath, language, transformed, result) + } + idx.applyRepoPrefix(result.Nodes, result.Edges) + parseErrors := 0 + for _, n := range result.Nodes { + if n != nil && n.Kind == graph.KindFile { + parseErrors, _ = n.Meta["parse_errors"].(int) + break + } + } + if result.ParseChecked && result.ParseErrors >= 0 { + parseErrors = result.ParseErrors + } + if time.Since(started) > projectionBuildDeadline { + return nil, ErrProjectionDeadline + } + return &Projection{ + GraphPath: idx.prefixPath(relPath), + Language: language, + ContentHash: contentHashForSource(transformed), + Nodes: result.Nodes, + Edges: result.Edges, + ParseErrors: parseErrors, + ParseChecked: result.ParseChecked && result.ParseErrors >= 0, + BuildTime: time.Since(started), + }, nil +} + +// DiffProjectedNodes lists how a projection's nodes differ from the nodes the +// store holds for the same file, compared by ID, kind and line range. It is +// empty when they agree. +func DiffProjectedNodes(projected, stored []*graph.Node) []string { + type span struct { + kind graph.NodeKind + start, end int + } + want := make(map[string]span, len(stored)) + for _, n := range stored { + if n != nil { + want[n.ID] = span{n.Kind, n.StartLine, n.EndLine} + } + } + var diffs []string + seen := make(map[string]bool, len(projected)) + for _, n := range projected { + // The store keeps one node per ID, and so does a layer. + if n == nil || seen[n.ID] { + continue + } + seen[n.ID] = true + got := span{n.Kind, n.StartLine, n.EndLine} + have, ok := want[n.ID] + switch { + case !ok: + diffs = append(diffs, fmt.Sprintf("only projected: %s %s %d-%d", n.ID, n.Kind, n.StartLine, n.EndLine)) + case have != got: + diffs = append(diffs, fmt.Sprintf("differs: %s projected %s %d-%d, stored %s %d-%d", + n.ID, got.kind, got.start, got.end, have.kind, have.start, have.end)) + } + delete(want, n.ID) + } + for id, s := range want { + diffs = append(diffs, fmt.Sprintf("only stored: %s %s %d-%d", id, s.kind, s.start, s.end)) + } + sort.Strings(diffs) + return diffs +} diff --git a/internal/indexer/project_source_test.go b/internal/indexer/project_source_test.go new file mode 100644 index 000000000..bc4058d93 --- /dev/null +++ b/internal/indexer/project_source_test.go @@ -0,0 +1,321 @@ +package indexer + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +// projectionFixtures holds one small source per language with the shapes a +// projection must reproduce: imports, constants, types, methods, calls and +// a TODO the coverage pass turns into a node. +var projectionFixtures = map[string]string{ + "go.mod": "module example.com/parity\n\ngo 1.22\n", + "sample.go": `package parity + +import ( + "fmt" + "strings" +) + +// TODO: tighten the limit. +const Limit = 3 + +type Greeter struct { + Name string +} + +func (g *Greeter) Greet(other string) string { + return fmt.Sprintf("%s greets %s", g.Name, strings.ToUpper(other)) +} + +func helper(n int) int { + if n > Limit { + return n + } + return helper(n + 1) +} + +var defaultGreeter = &Greeter{Name: "x"} +`, + // The index strips a byte-order mark before parsing and hashing. + "bom.go": "\xef\xbb\xbfpackage parity\n\nfunc Marked() int { return helper(1) }\n", + "web/sample.ts": `import { readFileSync } from "fs"; + +export const LIMIT = 3; + +export interface Shape { + area(): number; +} + +export class Square implements Shape { + constructor(private side: number) {} + area(): number { + return this.side * this.side; + } +} + +export function load(path: string): string { + // TODO: cache the result + return readFileSync(path, "utf8"); +} +`, + "py/sample.py": `import os + +LIMIT = 3 + + +class Greeter: + def __init__(self, name): + self.name = name + + def greet(self, other): + return helper(other) + + +def helper(value): + # TODO: validate the value + return os.path.join(value, "x") +`, + "c/sample.c": `#include + +#define LIMIT 3 + +struct point { + int x; + int y; +}; + +static int helper(int n) { + return n + LIMIT; +} + +int main(void) { + struct point p = {1, 2}; + printf("%d\n", helper(p.x)); + return 0; +} +`, + "java/sample/Greeter.java": `package sample; + +import java.util.List; + +public class Greeter { + private static final int LIMIT = 3; + private final String name; + + public Greeter(String name) { + this.name = name; + } + + public String greet(List others) { + return name + others.size() + LIMIT; + } +} +`, + "rust/src/lib.rs": `use std::fmt; + +pub const LIMIT: u32 = 3; + +pub struct Greeter { + name: String, +} + +impl Greeter { + pub fn greet(&self, other: &str) -> String { + format!("{} {}", self.name, helper(other)) + } +} + +fn helper(s: &str) -> String { + s.to_uppercase() +} + +impl fmt::Display for Greeter { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.name) + } +} +`, +} + +func newProjectionTestIndexer(t *testing.T, g graph.Store, cfg config.IndexConfig) *Indexer { + t.Helper() + registry := parser.NewRegistry() + languages.RegisterAll(registry) + cfg.Workers = 1 + idx := New(g, registry, cfg, zap.NewNop()) + t.Cleanup(idx.Close) + return idx +} + +func writeProjectionFixtures(t *testing.T, dir string) { + t.Helper() + for rel, content := range projectionFixtures { + path := filepath.Join(dir, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + } +} + +// The projection of a file equals what a full index stores for it, node for +// node, and carries the same content hash as the file's metadata row. Every +// language on the allowlist must hold this; the rest must refuse. +func TestProjectSourceGoldenParity(t *testing.T) { + dir := t.TempDir() + writeProjectionFixtures(t, dir) + g := graph.New() + idx := newProjectionTestIndexer(t, g, config.Default().Index) + idx.SetRepoPrefix("parity") + idx.SetWorkspaceID("ws") + idx.SetProjectID("proj") + _, err := idx.Index(dir) + require.NoError(t, err) + + for rel := range projectionFixtures { + if rel == "go.mod" { + continue + } + t.Run(rel, func(t *testing.T) { + abs := filepath.Join(dir, filepath.FromSlash(rel)) + src, err := os.ReadFile(abs) + require.NoError(t, err) + lang, ok := idx.effectiveLanguage(abs, src) + require.True(t, ok) + + projection, err := idx.ProjectSource(context.Background(), abs, src) + if !projectionLanguages[lang] { + require.ErrorIs(t, err, ErrProjectionLanguage) + return + } + require.NoError(t, err) + graphPath := "parity/" + rel + require.Equal(t, graphPath, projection.GraphPath) + require.NotEmpty(t, projection.Nodes) + require.True(t, projection.ParseChecked) + require.Zero(t, projection.ParseErrors) + require.Empty(t, DiffProjectedNodes(projection.Nodes, g.GetFileNodes(graphPath))) + for _, n := range projection.Nodes { + require.Equal(t, "parity", n.RepoPrefix, n.ID) + require.Equal(t, "ws", n.WorkspaceID, n.ID) + require.Equal(t, "proj", n.ProjectID, n.ID) + } + + rows, err := g.FileMetasByPaths("parity", []string{graphPath}) + require.NoError(t, err) + require.Equal(t, rows[graphPath].ContentHash, projection.ContentHash) + }) + } +} + +type slowGoExtractor struct{ delay time.Duration } + +func (slowGoExtractor) Language() string { return "go" } +func (slowGoExtractor) Extensions() []string { return []string{".go"} } +func (e slowGoExtractor) Extract(path string, _ []byte) (*parser.ExtractionResult, error) { + time.Sleep(e.delay) + return &parser.ExtractionResult{Nodes: []*graph.Node{{ID: path, Kind: graph.KindFile, FilePath: path}}}, nil +} + +func TestProjectSourceRefusals(t *testing.T) { + dir := t.TempDir() + goSource := []byte("package p\n\nfunc F() {}\n") + + t.Run("language off the allowlist", func(t *testing.T) { + idx := newProjectionTestIndexer(t, graph.New(), config.Default().Index) + idx.SetRootPath(dir) + _, err := idx.ProjectSource(t.Context(), filepath.Join(dir, "x.rb"), []byte("def f\nend\n")) + require.ErrorIs(t, err, ErrProjectionLanguage) + }) + t.Run("over 1 MiB", func(t *testing.T) { + idx := newProjectionTestIndexer(t, graph.New(), config.Default().Index) + idx.SetRootPath(dir) + big := append(bytes.Clone(goSource), bytes.Repeat([]byte("// padding\n"), (1<<20)/11+1)...) + _, err := idx.ProjectSource(t.Context(), filepath.Join(dir, "big.go"), big) + require.ErrorIs(t, err, ErrProjectionTooLarge) + }) + t.Run("command transform", func(t *testing.T) { + cfg := config.Default().Index + cfg.Transforms = []config.TransformRule{{ + Name: "must-not-run", + Extensions: []string{".go"}, + Command: []string{"gortex-test-transform-must-not-run"}, + }} + idx := newProjectionTestIndexer(t, graph.New(), cfg) + idx.SetRootPath(dir) + _, err := idx.ProjectSource(t.Context(), filepath.Join(dir, "f.go"), goSource) + require.ErrorIs(t, err, ErrProjectionTransform) + }) + t.Run("build past the deadline", func(t *testing.T) { + const parse = 1500 * time.Millisecond + registry := parser.NewRegistry() + registry.Register(slowGoExtractor{delay: parse}) + idx := New(graph.New(), registry, config.Default().Index, zap.NewNop()) + t.Cleanup(idx.Close) + idx.SetRootPath(dir) + started := time.Now() + _, err := idx.ProjectSource(t.Context(), filepath.Join(dir, "f.go"), goSource) + require.ErrorIs(t, err, ErrProjectionDeadline) + // The caller hears back at the deadline, not when the parse ends. + require.Less(t, time.Since(started), parse/2) + }) + t.Run("no parse slot", func(t *testing.T) { + idx := newProjectionTestIndexer(t, graph.New(), config.Default().Index) + idx.SetRootPath(dir) + held, err := acquireParseAdmission(t.Context(), projectionParseAdmission.capacity, 0, nil, projectionParseAdmission) + require.NoError(t, err) + _, err = idx.ProjectSource(t.Context(), filepath.Join(dir, "f.go"), goSource) + held.Release() + require.ErrorIs(t, err, ErrProjectionNoSlot) + }) +} + +// Bulk indexing can fill the shared parse budget for minutes. A projection +// build has its own reserve and must not queue behind it. +func TestInteractiveExtractionNotBlockedByFullSharedParseAdmission(t *testing.T) { + dir := t.TempDir() + writeProjectionFixtures(t, dir) + idx := newProjectionTestIndexer(t, graph.New(), config.Default().Index) + idx.SetRootPath(dir) + raw, native := newParseAdmissionBudget(1<<20), newParseAdmissionBudget(1<<20) + idx.parseAdmission.Store(raw) + idx.nativeParseAdmission.Store(native) + for _, budget := range []*parseAdmissionBudget{raw, native} { + held, err := acquireParseAdmission(t.Context(), budget.capacity, 0, nil, budget) + require.NoError(t, err) + t.Cleanup(held.Release) + } + + for _, rel := range []string{"sample.go", "c/sample.c"} { + abs := filepath.Join(dir, filepath.FromSlash(rel)) + src, err := os.ReadFile(abs) + require.NoError(t, err) + projection, err := idx.ProjectSource(t.Context(), abs, src) + require.NoError(t, err, rel) + require.True(t, strings.HasSuffix(projection.GraphPath, rel)) + require.NotEmpty(t, projection.Nodes) + } +} + +func TestProjectSourceUnknownVerdictCannotCertifyClean(t *testing.T) { + idx := newProjectionTestIndexer(t, graph.New(), config.Default().Index) + root := t.TempDir() + idx.SetRootPath(root) + idx.registry.Register(slowGoExtractor{}) + projection, err := idx.ProjectSource(context.Background(), filepath.Join(root, "main.go"), []byte("package p\n")) + require.NoError(t, err) + require.False(t, projection.ParseChecked) + require.Zero(t, projection.ParseErrors) +} diff --git a/internal/indexer/warmup_wal_boundary_test.go b/internal/indexer/warmup_wal_boundary_test.go new file mode 100644 index 000000000..5145fd956 --- /dev/null +++ b/internal/indexer/warmup_wal_boundary_test.go @@ -0,0 +1,29 @@ +package indexer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +func TestWarmupWALBoundaryRefusesBeforeChunkReadsOrWrites(t *testing.T) { + counting := &incrementalBatchCountingStore{Store: graph.New()} + idx := newTestIndexer(counting) + calls := 0 + idx.warmupWriteBoundary = func() error { + calls++ + return context.Canceled + } + _, reparsed, failed, _ := idx.reindexIncrementalStalePass( + []string{"must-not-read.go"}, &reparsePendingEnrichmentBatch{}, false, + ) + require.Equal(t, 1, calls) + require.ErrorIs(t, idx.contractRestatementErr, context.Canceled) + require.Empty(t, reparsed) + require.Equal(t, []string{"must-not-read.go"}, failed) + require.Zero(t, counting.getFileNodesByPaths.Load()) + require.Zero(t, counting.addBatch.Load()) +} diff --git a/internal/indexer/watcher.go b/internal/indexer/watcher.go index 0b1d05da1..4912e78be 100644 --- a/internal/indexer/watcher.go +++ b/internal/indexer/watcher.go @@ -65,12 +65,15 @@ type MutationTicket struct { // events have none. The watcher binds it before waking work; the existing // consumer of Done records completion, never a second channel reader. PublicationRecord *PublicationPhaseRecord `json:"-"` + tierMu sync.RWMutex + tiers MutationTierState } // mutationWaiter owns only outstanding ticket state. Publication metadata is // released with the same completion/stop paths as its existing result channel; // it is not a separate tracing registry or completed-history collection. type mutationWaiter struct { + ticket *MutationTicket tool bool done chan MutationResult publication *PublicationPhaseRecord @@ -189,7 +192,8 @@ type Watcher struct { pending map[string]*time.Timer pendingGeneration map[string]uint64 mutationWaiters map[string]map[uint64]mutationWaiter - heldNative map[string]ChangeKind // native echoes awaiting outstanding tool receipts; guarded by mu + failedPaths map[string]MutationTierState // retained failures only; guarded by mu + heldNative map[string]ChangeKind // native echoes awaiting outstanding tool receipts; guarded by mu mutationEchoesAttached uint64 mutationSuperseded uint64 mutationWaitersLater uint64 @@ -218,6 +222,8 @@ type Watcher struct { stoppedOnce sync.Once symbolChangeCb SymbolChangeCallback symbolChangeCbMu sync.RWMutex + sourceEventCb func(string) + sourceEventCbMu sync.RWMutex // Degraded-watch state: set when the OS can't fully cover the tree — // inotify watch exhaustion (ENOSPC) or FD exhaustion (EMFILE/ENFILE). @@ -251,21 +257,20 @@ type Watcher struct { // Storm-mode state. Guarded by stormMu so the hot per-file // debounce path (mu) doesn't contend with rate-tracking. - stormMu sync.Mutex - eventTimes []time.Time // sliding window of recent event timestamps - stormBatch map[string]ChangeKind // dirty set during an event storm - stormGenerations map[string]uint64 // newest debounced generation adopted per path - stormTimer *time.Timer // fires after the quiet period - stormActive bool // true while waiting to drain - stormExplicit bool // admitted complete explicit frontier requires forced reads - stormStopped bool // Stop has closed storm admission - stormRetryAttempt int // retry ordinal for the published storm timer - stormWork sync.WaitGroup // scheduled/running timer callbacks - stormDrained func(int) // test hook: batch drained; batch size arg - stormBeforeLock func() // test hook: immediately before repository-lane admission - explicitReindexRaw watcherBatchReindex // complete forced frontier; caller already holds repository lane - batchReindex watcherBatchReindex // one bounded batch; MultiWatcher installs shared catch-up - discoverReindex watcherBatchReindex // additive directory discovery with the same complete tail + stormMu sync.Mutex + eventTimes []time.Time // sliding window of recent event timestamps + stormBatch map[string]ChangeKind // dirty set during an event storm + stormGenerations map[string]uint64 // newest debounced generation adopted per path + stormTimer *time.Timer // fires after the quiet period + stormActive bool // true while waiting to drain + stormExplicit bool // admitted complete explicit frontier requires forced reads + stormStopped bool // Stop has closed storm admission + stormRetryAttempt int // retry ordinal for the published storm timer + stormWork sync.WaitGroup // scheduled/running timer callbacks + stormDrained func(int) // test hook: batch drained; batch size arg + stormBeforeLock func() // test hook: immediately before repository-lane admission + batchReindex watcherBatchReindex // one bounded batch; MultiWatcher installs shared catch-up + discoverReindex watcherBatchReindex // additive directory discovery with the same complete tail // pointReindexRaw runs the complete exact-path pipeline after the watcher // already owns the repository lane. MultiWatcher installs its raw tail here. pointReindexRaw func(string, *PublicationStamps) (*IndexResult, error) @@ -1311,6 +1316,23 @@ func (w *Watcher) OnSymbolChange(cb SymbolChangeCallback) { w.symbolChangeCb = cb } +// OnSourceEvent replaces the callback invoked synchronously for a source path +// before storm handling or debounce. The callback must return promptly. +func (w *Watcher) OnSourceEvent(cb func(string)) { + w.sourceEventCbMu.Lock() + w.sourceEventCb = cb + w.sourceEventCbMu.Unlock() +} + +func (w *Watcher) notifySourceEvent(path string) { + w.sourceEventCbMu.RLock() + cb := w.sourceEventCb + w.sourceEventCbMu.RUnlock() + if cb != nil { + cb(path) + } +} + // OnDegraded registers a callback invoked once when the file watcher first // enters a degraded state (inotify / FD exhaustion, a stopped backend, an // incomplete startup barrier). The daemon wires it to its health @@ -1735,6 +1757,7 @@ func (w *Watcher) handleEvent(event fswatcher.WatchEvent) { } return } + w.notifySourceEvent(path) // Before the exclusion check: an ignore file can sit in a folder whose // own events are excluded, and its rules apply all the same. @@ -1856,6 +1879,7 @@ func (w *Watcher) mutationAdmissionPath(ctx context.Context, filePath string) (s return "", errWatcherStopped default: } + w.indexer.noteToolWrite(rel) return absPath, nil } @@ -1947,11 +1971,12 @@ func (w *Watcher) EnqueueFileMutations(ctx context.Context, files []string) (map w.mutationWaiters[path] = make(map[uint64]mutationWaiter) } done := make(chan MutationResult, 1) - w.mutationWaiters[path][generation] = mutationWaiter{done: done, tool: true} + ticket := newMutationTierTicket(path, generation, done) + w.mutationWaiters[path][generation] = mutationWaiter{done: done, tool: true, ticket: ticket} w.pendingGeneration[path] = generation w.stormGenerations[path] = generation w.stormBatch[path] = ChangeModified - tickets[path] = &MutationTicket{Path: path, Generation: generation, Done: done} + tickets[path] = ticket } w.stormActive = true w.stormExplicit = true @@ -2041,7 +2066,7 @@ func (w *Watcher) scheduleMutationEnteredEvent(path string, kind ChangeKind, rec } done := make(chan MutationResult, 1) w.pendingGeneration[path] = generation - ticket := &MutationTicket{Path: path, Generation: generation, Done: done} + ticket := newMutationTierTicket(path, generation, done) if record != nil && record.bindCanonicalTicket(generation, time.Now()) { ticket.PublicationRecord = record if w.observedMutationGeneration == nil { @@ -2049,7 +2074,7 @@ func (w *Watcher) scheduleMutationEnteredEvent(path string, kind ChangeKind, rec } w.observedMutationGeneration[path] = generation } - w.mutationWaiters[path][generation] = mutationWaiter{done: done, publication: ticket.PublicationRecord, schedulingEntered: entered, backgroundEligible: backgroundEligible, tool: tool} + w.mutationWaiters[path][generation] = mutationWaiter{done: done, ticket: ticket, publication: ticket.PublicationRecord, schedulingEntered: entered, backgroundEligible: backgroundEligible, tool: tool} delay := time.Duration(w.config.DebounceMs) * time.Millisecond if tool { delay = 0 @@ -2367,6 +2392,7 @@ func (w *Watcher) completeMutationWaitersObserved( var accepted *PublicationPhaseRecord for generation, waiter := range w.mutationWaiters[path] { if generation <= appliedGeneration { + w.finishMutationTierLocked(path, waiter.ticket, err, fanout) if err == nil && generation < appliedGeneration { w.mutationWaitersLater++ } @@ -2415,6 +2441,11 @@ func (w *Watcher) completeMutationWaitersObserved( func (w *Watcher) failMutationWaiters(err error) { w.mu.Lock() waiters := w.mutationWaiters + for path, byGeneration := range waiters { + for _, waiter := range byGeneration { + w.finishMutationTierLocked(path, waiter.ticket, err, DerivedFanoutCompleteness{}) + } + } w.mutationWaiters = nil w.heldNative = nil w.observedMutationGeneration = nil @@ -2527,6 +2558,29 @@ func (w *Watcher) recordInStormEvent(path string, kind ChangeKind, native bool) w.stormGenerations[path] = generation } } + // A native-only storm path has no stopped point timer to adopt. Give it + // the same path generation and live ticket evidence as point admission. + if w.stormGenerations[path] == 0 { + w.nextGeneration++ + generation := w.nextGeneration + if w.stormGenerations == nil { + w.stormGenerations = make(map[string]uint64) + } + if w.pendingGeneration == nil { + w.pendingGeneration = make(map[string]uint64) + } + if w.mutationWaiters == nil { + w.mutationWaiters = make(map[string]map[uint64]mutationWaiter) + } + if w.mutationWaiters[path] == nil { + w.mutationWaiters[path] = make(map[uint64]mutationWaiter) + } + done := make(chan MutationResult, 1) + ticket := newMutationTierTicket(path, generation, done) + w.mutationWaiters[path][generation] = mutationWaiter{done: done, ticket: ticket} + w.pendingGeneration[path] = generation + w.stormGenerations[path] = generation + } w.mu.Unlock() w.stormBatch[path] = kind w.stormRetryAttempt = 0 @@ -2624,9 +2678,9 @@ func (w *Watcher) drainStorm() { var err error var fanout DerivedFanoutCompleteness if explicit { - result, fanout, err = w.reindexExplicitMutationPaths(paths) + result, fanout, err = w.reindexExplicitMutationPaths(paths, generations) } else { - result, err = w.reindexStormPaths(paths) + result, fanout, err = w.reindexStormPathsObserved(paths, generations) } reindexed, deleted, failed := 0, 0, 0 @@ -2751,6 +2805,7 @@ func (w *Watcher) completeStormMutationWaiters( if requestedGeneration > appliedGeneration { continue } + w.finishMutationTierLocked(path, waiter.ticket, pathErr, pathFanout) if pathErr == nil && requestedGeneration < appliedGeneration { w.mutationWaitersLater++ } @@ -2946,14 +3001,21 @@ func pointMutationClassification( // reindexPointPathRaw runs after the caller has acquired the repository lane. // MultiWatcher supplies the cross-repository resolver and derived tail; a // standalone Watcher falls back to the equivalent single-repository tail. -func (w *Watcher) reindexPointPathRaw(idx *Indexer, path string, stamps *PublicationStamps) (*IndexResult, error) { +func (w *Watcher) reindexPointPathRaw(idx *Indexer, path string, generation uint64, stamps *PublicationStamps) (*IndexResult, error) { if w.pointReindexRaw != nil { return w.pointReindexRaw(path, stamps) } if idx == nil || idx.rootPath == "" { return nil, errWatcherIndexerMissing } - return idx.incrementalPointWatcherPathObserved(idx.rootPath, path, stamps) + mode := incrementalPathMode{ + detectDeletions: true, forceExplicitFiles: true, publication: stamps, + onRelations: func() { w.markRelations(path, generation) }, + } + if owner := idx.repositoryMutationOwner; owner != nil && idx.repoPrefix != "" { + return owner.incrementalReindexRepoRawMode(idx.repoPrefix, []string{path}, mode) + } + return idx.incrementalWatcherPaths(idx.rootPath, []string{path}, mode) } // patchGraphWithReceiptStateRawModern replaces the historical hand-built point @@ -3038,7 +3100,7 @@ func (w *Watcher) patchGraphWithReceiptStateRawModern( observation := beginDerivedFanoutObservation(idx) defer observation.close() stamps.switchAttemptControlAt(patchExecutorControl, markPointPublication(ctx, PublicationPatchExecutionStarted)) - result, err := w.reindexPointPathRaw(idx, path, PublicationStampsFrom(ctx)) + result, err := w.reindexPointPathRaw(idx, path, generation, PublicationStampsFrom(ctx)) stamps.switchAttemptControlAt(patchAttemptFinalize, markPointPublication(ctx, PublicationPatchExecutionFinished)) observed := observation.close() if err != nil { diff --git a/internal/indexer/watcher_mutation_set_test.go b/internal/indexer/watcher_mutation_set_test.go index 289ce6265..52766873b 100644 --- a/internal/indexer/watcher_mutation_set_test.go +++ b/internal/indexer/watcher_mutation_set_test.go @@ -46,9 +46,17 @@ func TestWatcherMutationSetParsesWholeFrontierBeforeResolution(t *testing.T) { _, err := idx.Index(dir) require.NoError(t, err) mi := &MultiIndexer{graph: g, repos: map[string]*RepoMetadata{"repo": {RepoPrefix: "repo", RootPath: dir, FileMtimes: idx.FileMtimes()}}, indexers: map[string]*Indexer{"repo": idx}, logger: zap.NewNop()} + // This hand-built Indexer bypasses the production MultiIndexer factory. + // Retain its controlled hooks while installing the factory's owner binding. + idx.repositoryMutationOwner = mi mw, err := NewMultiWatcher(mi, map[string]config.WatchConfig{"repo": {DebounceMs: 60000}}, zap.NewNop()) require.NoError(t, err) w := mw.watchers["repo"] + require.NotNil(t, w) + current := w.currentMutationIndexer() + require.NotNil(t, current) + require.NotNil(t, current.repositoryMutationOwner) + require.Same(t, mi, current.repositoryMutationOwner) w.degradedNoFsnotify = true t.Cleanup(func() { require.NoError(t, w.Stop()) }) writeTestFile(t, caller, "package pair\nfunc Caller(){Renamed()}\n") diff --git a/internal/indexer/watcher_publication_execution_test.go b/internal/indexer/watcher_publication_execution_test.go index 4976160f5..6ba7ca900 100644 --- a/internal/indexer/watcher_publication_execution_test.go +++ b/internal/indexer/watcher_publication_execution_test.go @@ -100,7 +100,16 @@ func TestCanonicalPublicationObservesMultiWatcherExecutor(t *testing.T) { }) } w := mw.watchers["repo-a"] - require.NotNil(t, w.pointReindexRaw, "production MultiWatcher must install the daemon's MultiIndexer executor") + idx := w.currentMutationIndexer() + require.NotNil(t, idx, "production MultiWatcher must select its registered Indexer") + require.NotNil(t, idx.repositoryMutationOwner, "production Indexer must bind the daemon executor") + require.Same(t, mi, idx.repositoryMutationOwner) + var crossCalls atomic.Int32 + idx.incrementalCatchupHook = func(kind string, _ []string) { + if kind == "cross_repo" { + crossCalls.Add(1) + } + } path := filepath.Join(root, "main.go") bumpMtime(t, path, "package main\n\nfunc ChangedByDaemonPath() {}\n") record := newPointPublication(t, "multi") @@ -108,6 +117,7 @@ func TestCanonicalPublicationObservesMultiWatcherExecutor(t *testing.T) { require.NoError(t, err) require.NoError(t, awaitMutationRetryResult(t, ticket).Err) require.NotNil(t, nodeByName(t, mi.GetIndexer("repo-a"), "repo-a/main.go", "ChangedByDaemonPath")) + require.EqualValues(t, 1, crossCalls.Load(), "the real daemon resolver tail must run, not the standalone fallback") requirePointExecution(t, record, true) } diff --git a/internal/indexer/watcher_source_event_test.go b/internal/indexer/watcher_source_event_test.go new file mode 100644 index 000000000..e94ea5288 --- /dev/null +++ b/internal/indexer/watcher_source_event_test.go @@ -0,0 +1,100 @@ +package indexer + +import ( + "path/filepath" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/config" +) + +func TestWatcherSourceEventConcurrentRegistration(t *testing.T) { + w := &Watcher{} + var calls atomic.Int64 + callback := func(string) { calls.Add(1) } + var done sync.WaitGroup + done.Add(1) + go func() { + defer done.Done() + for range 100 { + w.OnSourceEvent(callback) + w.OnSourceEvent(nil) + } + }() + for range 100 { + w.notifySourceEvent("a.go") + } + done.Wait() + w.OnSourceEvent(callback) + before := calls.Load() + w.notifySourceEvent("a.go") + require.Equal(t, before+1, calls.Load()) + w.OnSourceEvent(nil) + w.notifySourceEvent("a.go") + require.Equal(t, before+1, calls.Load()) +} + +func TestMultiWatcherSourceEventRegistrationFollowsNewWatchers(t *testing.T) { + mw, mi, repoA, _ := setupMultiWatcherTest(t) + t.Cleanup(func() { _ = mw.Stop() }) + var first, second atomic.Int64 + eventPath := filepath.Join(repoA, "delivered.go") + mw.OnSourceEvent(func(path string) { + if path == eventPath { + first.Add(1) + } + }) + mw.watchers["repo-a"].notifySourceEvent(eventPath) + require.EqualValues(t, 1, first.Load()) + + // Replacement takes effect on already-created watchers. A callback may + // register its successor because delivery holds no registration lock. + callback := func(path string) { + if path == eventPath { + second.Add(1) + } + } + mw.OnSourceEvent(func(path string) { + if path == eventPath { + second.Add(1) + mw.OnSourceEvent(callback) + } + }) + mw.watchers["repo-a"].notifySourceEvent(eventPath) + require.EqualValues(t, 1, first.Load()) + require.EqualValues(t, 1, second.Load()) + + newRepo := t.TempDir() + writeFile(t, filepath.Join(newRepo, "main.go"), "package main\nfunc Added() {}\n") + _, err := mi.TrackRepo(config.RepoEntry{Path: newRepo, Name: "repo-new"}) + require.NoError(t, err) + require.NoError(t, mw.AddRepo("repo-new", config.WatchConfig{Enabled: true, DebounceMs: 60_000})) + mw.mu.Lock() + added := mw.watchers["repo-new"] + mw.mu.Unlock() + require.NotNil(t, added) + before := second.Load() + added.notifySourceEvent(eventPath) + require.Equal(t, before+1, second.Load()) + + var done sync.WaitGroup + done.Add(1) + go func() { + defer done.Done() + for range 100 { + mw.OnSourceEvent(callback) + mw.OnSourceEvent(nil) + } + }() + for range 100 { + added.notifySourceEvent(eventPath) + } + done.Wait() + mw.OnSourceEvent(nil) + before = second.Load() + added.notifySourceEvent(eventPath) + require.Equal(t, before, second.Load()) +} diff --git a/internal/indexer/watcher_text_search_test.go b/internal/indexer/watcher_text_search_test.go new file mode 100644 index 000000000..175cbce24 --- /dev/null +++ b/internal/indexer/watcher_text_search_test.go @@ -0,0 +1,66 @@ +package indexer + +import ( + "context" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// All committed-mutation front doors must expose the written bytes while the +// graph patch is deliberately held back; admission does not wait on reindex. +func TestWatcherAdmittedEditVisibleToTextSearch(t *testing.T) { + for _, admission := range []string{"debounced", "tool", "set"} { + t.Run(admission, func(t *testing.T) { + dir, idx, w := inertTestWatcher(t, "main.go", "package main\n\nfunc value() int { return 0 }\n") + now := time.Unix(1_700_000_000, 0) + idx.trigramBudgetOverride = newTestTrigramBudget(time.Hour, 3, &now) + require.NotEmpty(t, idx.GrepText("func value", 10)) + require.NotNil(t, idx.trigramSearcher, "the searcher is warm") + + path := filepath.Join(dir, "main.go") + w.patchMu.Lock() + var unlocked sync.Once + unlock := func() { unlocked.Do(w.patchMu.Unlock) } + defer unlock() + writeTestFile(t, path, "package main\n\nfunc value() int { return admittedValue }\n\nconst admittedValue = 1\n") + var tickets []*MutationTicket + switch admission { + case "debounced", "tool": + admit := w.EnqueueFileMutation + if admission == "tool" { + admit = w.EnqueueToolMutation + } + ticket, err := admit(context.Background(), path) + require.NoError(t, err) + require.NotNil(t, ticket) + tickets = append(tickets, ticket) + case "set": + side := filepath.Join(dir, "side.go") + writeTestFile(t, side, "package main\nconst sideValue = 1\n") + admitted, err := w.EnqueueFileMutations(context.Background(), []string{path, side}) + require.NoError(t, err) + require.Len(t, admitted, 2) + for _, ticket := range admitted { + tickets = append(tickets, ticket) + } + require.Len(t, idx.GrepText("sideValue", 10), 1, "a newly written untracked member must be searchable before the set drains") + } + matches := idx.GrepText("admittedValue", 10) + unlock() + for _, ticket := range tickets { + select { + case <-ticket.Done: + case <-time.After(5 * time.Second): + t.Fatal("the admitted edit was never reindexed") + } + } + require.Len(t, matches, 2) + require.Equal(t, 3, matches[0].Line) + require.Equal(t, 5, matches[1].Line) + }) + } +} diff --git a/internal/mcp/batch_commit_boundary_test.go b/internal/mcp/batch_commit_boundary_test.go new file mode 100644 index 000000000..12ecdd535 --- /dev/null +++ b/internal/mcp/batch_commit_boundary_test.go @@ -0,0 +1,119 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/agents" +) + +func TestBatchRollbackRequiresLiveHeldRecoveryPaths(t *testing.T) { + for _, mode := range []string{"held", "cancelled-held", "unheld", "released", "wrong-path"} { + t.Run(mode, func(t *testing.T) { + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + path := writeAtomicBatchFixture(t, t.TempDir(), "source.txt", "before\n") + receipt := prepareAtomicRecoveryFixture(t, s, "held-restore", []string{path}, []string{"before\n"}, []string{"after\n"}) + require.NoError(t, agents.AtomicWriteFile(path, []byte("after\n"), 0o644)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if mode != "unheld" { + lockedPath := path + if mode == "wrong-path" { + lockedPath = filepath.Join(filepath.Dir(path), "other.txt") + } + var release func() + var err error + ctx, release, err = acquireMutationPath(ctx, lockedPath) + require.NoError(t, err) + defer release() + if mode == "released" { + release() + } + } + if mode == "cancelled-held" { + cancel() // restore is terminalization of already committed bytes + } + status, err := s.rollbackBatchReceipt(receipt, ctx) + current, readErr := os.ReadFile(path) + require.NoError(t, readErr) + if mode == "held" || mode == "cancelled-held" { + require.NoError(t, err) + require.Equal(t, "aborted", status) + require.Equal(t, "before\n", string(current)) + } else { + assert.Error(t, err, "restore must prove that its original recovery path set is still held") + assert.Equal(t, "recovery_conflict", status) + assert.Equal(t, "after\n", string(current), "a refused restore must not touch source bytes") + } + _, physical := s.mutationCommits.recentForPath(path) + require.False(t, physical, "the durable batch remains the only source authority") + }) + } +} + +func TestBatchForwardWritesKeepTransactionAsOnlySourceAuthority(t *testing.T) { + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + path := writeAtomicBatchFixture(t, t.TempDir(), "source.txt", "before\n") + ctx, note := withMutationCommitNote(t.Context()) + ctx, ack, _ := s.withEditAcknowledgement(ctx) + defer ack.finishActual() + s.batchWriteOverride = func(path string, content []byte, mode os.FileMode) error { + _, physical := s.mutationCommits.recentForPath(path) + require.False(t, physical, "a forward file write must not pre-empt the transaction verdict") + require.Empty(t, note.verdict().Snapshots, "an abandoned batch must use its durable transaction status") + return agents.AtomicWriteFile(path, content, mode) + } + receipt, err := s.runBatchTransaction(ctx, []batchEditItem{atomicFileEdit(path, "before", "after")}, "only-batch-authority") + require.NoError(t, err) + require.Equal(t, "committed", receipt.Status) + require.Equal(t, "committed", receipt.DiskStatus) + require.Equal(t, "after\n", readAtomicBatchFixture(t, path)) + _, physical := s.mutationCommits.recentForPath(path) + require.False(t, physical) + require.Empty(t, note.verdict().Snapshots) +} + +func TestBatchSourceOperationsKeepHeldAuthorityThroughTerminalization(t *testing.T) { + for _, kind := range []sourceMutationOperationKind{batchForwardWrite, batchForwardRemove, batchRestoreWrite, batchRestoreRemove} { + for _, held := range []bool{false, true} { + t.Run(fmt.Sprintf("operation_%d/held_%t", kind, held), func(t *testing.T) { + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + path := writeAtomicBatchFixture(t, t.TempDir(), "source.txt", "before\n") + ctx, note := withMutationCommitNote(t.Context()) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + if held { + var release func() + var err error + ctx, release, err = acquireMutationPath(ctx, path) + require.NoError(t, err) + defer release() + } + cancel() // a prepared transaction must finish after this boundary + record, err := s.commitFileMutation(ctx, "batch_edit", "", "", "source.txt", path, []byte("after\n"), 0o644, sourceMutationOperation{kind: kind}) + require.Nil(t, record) + if !held { + require.ErrorIs(t, err, errMutationNotApplied) + require.Equal(t, "before\n", readAtomicBatchFixture(t, path)) + } else { + require.NoError(t, err) + if kind == batchForwardRemove || kind == batchRestoreRemove { + _, statErr := os.Stat(path) + require.ErrorIs(t, statErr, os.ErrNotExist) + } else { + require.Equal(t, "after\n", readAtomicBatchFixture(t, path)) + } + } + _, physical := s.mutationCommits.recentForPath(path) + require.False(t, physical) + require.Empty(t, note.verdict().Snapshots) + }) + } + } +} diff --git a/internal/mcp/batch_file_lifecycle_dry_run_test.go b/internal/mcp/batch_file_lifecycle_dry_run_test.go index bc4745de4..d65710e7e 100644 --- a/internal/mcp/batch_file_lifecycle_dry_run_test.go +++ b/internal/mcp/batch_file_lifecycle_dry_run_test.go @@ -535,7 +535,7 @@ func TestBatchEditDryRunTakesNoMutationPathLock(t *testing.T) { source := writeAtomicBatchFixture(t, repoRoot, "source.txt", "source\n") destination := filepath.Join(repoRoot, "destination.txt") - release, err := acquireMutationPaths(context.Background(), []string{source, destination}) + _, release, err := acquireMutationPaths(context.Background(), []string{source, destination}) if err != nil { t.Fatal(err) } diff --git a/internal/mcp/batch_pending_view_contract_test.go b/internal/mcp/batch_pending_view_contract_test.go new file mode 100644 index 000000000..b240bad0b --- /dev/null +++ b/internal/mcp/batch_pending_view_contract_test.go @@ -0,0 +1,181 @@ +package mcp + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" +) + +// Exercise the durable writer boundary rather than manually constructing a +// projection cohort. A partial physical forward write is not batch authority. +func TestBatchPendingViewsRequireCompleteDurableFrontier(t *testing.T) { + for _, rollback := range []bool{false, true} { + name := "commit" + if rollback { + name = "rollback" + } + t.Run(name, func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions")) + s, root := setupTestServer(t) + done := make(chan indexer.MutationResult) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + s.mutationReindexWait = time.Nanosecond + a := filepath.Join(root, "main.go") + b := writeAtomicBatchFixture(t, root, "second.go", "package main\nfunc SecondBefore() {}\n") + _, err := s.indexer.IncrementalReindexPaths(root, []string{b}) + require.NoError(t, err) + beforeA := readAtomicBatchFixture(t, a) + beforeB := readAtomicBatchFixture(t, b) + writes, failed := 0, false + s.batchWriteOverride = func(path string, content []byte, mode os.FileMode) error { + if failed { + return durableAtomicWriteFile(path, content, mode) + } + writes++ + require.NoError(t, durableAtomicWriteFile(path, content, mode)) + if writes == 1 { + require.Nil(t, liveEntry(pendingFileProjections, a), "one successful writer cannot publish half a batch") + require.Nil(t, liveEntry(pendingFileProjections, b), "unwritten sibling cannot become a pending view") + require.Nil(t, s.graph.GetNode("main.go::BatchFirstAfter")) + require.Nil(t, s.graph.GetNode("second.go::BatchSecondAfter")) + } + if writes == 2 && rollback { + failed = true + return errors.New("deliberate second forward writer failure after bytes changed") + } + return nil + } + s.batchDurabilityOverride = &batchDurabilityOps{syncDirectory: func(path string) error { + if filepath.Clean(path) == filepath.Clean(root) { + require.Nil(t, liveEntry(pendingFileProjections, a), "directory durability must precede cache admission") + require.Nil(t, liveEntry(pendingFileProjections, b)) + } + return syncBatchDirectory(path) + }} + res := callBatchEdit(t, s, map[string]any{"transaction_id": "pending-view-" + name, "edits": []any{ + map[string]any{"op": "edit_file", "path": a, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc BatchFirstAfter() {}"}, + map[string]any{"op": "edit_file", "path": b, "old_string": "func SecondBefore() {}", "new_string": "func BatchSecondAfter() {}"}, + }}) + var receipt batchTransactionReceipt + require.NoError(t, json.Unmarshal([]byte(readText(t, res)), &receipt)) + require.GreaterOrEqual(t, writes, 2) + if rollback { + require.Equal(t, "rolled_back", receipt.DiskStatus) + require.Equal(t, beforeA, readAtomicBatchFixture(t, a)) + require.Equal(t, beforeB, readAtomicBatchFixture(t, b)) + require.Nil(t, liveEntry(pendingFileProjections, a)) + require.Nil(t, liveEntry(pendingFileProjections, b)) + return + } + require.Equal(t, "committed", receipt.DiskStatus) + require.Equal(t, "pending", receipt.GraphStatus) + first, second := liveEntry(pendingFileProjections, a), liveEntry(pendingFileProjections, b) + require.NotNil(t, first, "durable batch bytes need a pending file view before the watcher finishes") + require.NotNil(t, second, "the complete durable frontier must publish together") + require.NotNil(t, graph.NewOverlaidView(s.graph, first.layer).GetNode("main.go::BatchFirstAfter")) + require.NotNil(t, graph.NewOverlaidView(s.graph, second.layer).GetNode("second.go::BatchSecondAfter")) + require.Positive(t, first.commitSeq) + require.Equal(t, first.commitSeq, second.commitSeq, "one successful durable frontier has one process-local sequence") + response := s.batchProjectionResponse(receipt) + require.Equal(t, first.commitSeq, response.CommitSeq) + require.Len(t, response.FileViews, 2) + for _, view := range response.FileViews { + require.True(t, view.ReadbackExact) + require.Equal(t, "projected", view.FileView.State) + require.NotEmpty(t, view.ContentSHA256) + require.Equal(t, "file current; recorded graph status: pending", view.GraphStatusHint) + } + journal, err := os.ReadFile(batchManifestPath(receipt.TransactionID)) + require.NoError(t, err) + require.NotContains(t, string(journal), "commit_seq", "process-local projection origin is never journal authority") + status := callBatchEdit(t, s, map[string]any{"transaction_id": receipt.TransactionID, "status_only": true}) + require.False(t, status.IsError) + require.Equal(t, 2, writes, "status cannot repeat forward source effects") + require.Equal(t, response.CommitSeq, s.batchProjectionResponse(receipt).CommitSeq) + restarted := &Server{watcher: mutationTestWatcher{done: done}, session: newSessionState(), mutationReindexWait: time.Nanosecond} + recovered, err := restarted.batchTransactionStatus(t.Context(), receipt.TransactionID) + require.NoError(t, err) + require.Equal(t, "committed", recovered.DiskStatus) + restartResponse := restarted.batchProjectionResponse(recovered) + require.Zero(t, restartResponse.CommitSeq) + for _, view := range restartResponse.FileViews { + require.False(t, view.ReadbackExact, "even an existing live cache cannot reconstruct an original process-local origin") + require.Equal(t, "unavailable", view.FileView.State) + } + require.Nil(t, s.graph.GetNode("main.go::BatchFirstAfter"), "pending file evidence is not store freshness") + require.Nil(t, s.graph.GetNode("second.go::BatchSecondAfter")) + }) + } +} + +func TestBatchPendingCohortRefusalKeepsEveryPriorEntry(t *testing.T) { + for _, reason := range []string{"file-cap", "byte-cap", "newer-pointer"} { + t.Run(reason, func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions")) + s, root := setupTestServer(t) + done := make(chan indexer.MutationResult) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + s.mutationReindexWait = time.Nanosecond + a := filepath.Join(root, "main.go") + b := writeAtomicBatchFixture(t, root, "sibling.go", "package main\nfunc SiblingBefore() {}\n") + _, err := s.indexer.IncrementalReindexPaths(root, []string{b}) + require.NoError(t, err) + if reason == "file-cap" { + for i := 0; i < projectionMaxFiles-1; i++ { + name := fmt.Sprintf("filler-%03d.go", i) + require.NoError(t, pendingFileProjections.publish(syntheticProjectionEntry(filepath.Join(root, name), name, "h", 1, diskStamp{}, nil))) + } + } + if reason == "byte-cap" { + filler := syntheticProjectionEntry(filepath.Join(root, "filler.go"), "filler.go", "h", 1, diskStamp{}, nil) + filler.size = projectionMaxBytes - 1 + require.NoError(t, pendingFileProjections.publish(filler)) + } + before := pendingFileProjections.snap.Load() + var newer *projectionEntry + s.batchWriteOverride = func(path string, content []byte, mode os.FileMode) error { + if err := durableAtomicWriteFile(path, content, mode); err != nil { + return err + } + if reason == "newer-pointer" && path == a { + stamp, err := statDiskStamp(a) + require.NoError(t, err) + newer = syntheticProjectionEntry(a, "main.go", "newer", ^uint64(0), stamp, content) + require.NoError(t, pendingFileProjections.publish(newer)) + before = pendingFileProjections.snap.Load() + } + return nil + } + result := callBatchEdit(t, s, map[string]any{"transaction_id": "refuse-" + reason, "edits": []any{ + map[string]any{"op": "edit_file", "path": a, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc RefusedFirst() {}"}, + map[string]any{"op": "edit_file", "path": b, "old_string": "func SiblingBefore() {}", "new_string": "func RefusedSecond() {}"}, + }}) + var receipt batchTransactionReceipt + require.NoError(t, json.Unmarshal([]byte(readText(t, result)), &receipt)) + require.Equal(t, "committed", receipt.DiskStatus, "cache refusal never reverses a durable batch") + require.Same(t, before, pendingFileProjections.snap.Load(), "one rejected member cannot replace any prior entry") + require.Same(t, newer, liveEntry(pendingFileProjections, a)) + require.Nil(t, liveEntry(pendingFileProjections, b)) + for _, view := range s.batchProjectionResponse(receipt).FileViews { + require.False(t, view.ReadbackExact) + require.Equal(t, "unavailable", view.FileView.State) + } + }) + } +} diff --git a/internal/mcp/batch_projection.go b/internal/mcp/batch_projection.go new file mode 100644 index 000000000..64a706b7a --- /dev/null +++ b/internal/mcp/batch_projection.go @@ -0,0 +1,175 @@ +package mcp + +import ( + "context" + "github.com/zzet/gortex/internal/pathkey" + "sync" + "time" +) + +// Preparations live only on the original transaction's stack. The existing +// bounded projection registry is the sole owner of admitted file graphs. +func (s *Server) prepareBatchProjections(ctx context.Context, buffers map[string]*batchFileBuffer, paths []string) map[string]*preparedFileProjection { + if len(paths) > projectionMaxFiles { + return nil + } + lap := s.beginAcknowledgementLap(ctx, editAckParseGate) + workCtx, cancel := context.WithCancel(ctx) + defer cancel() + results := make([]*preparedFileProjection, len(paths)) + jobs := make(chan int) + var workers sync.WaitGroup + var budgetMu sync.Mutex + var preparedBytes int64 + projectedFiles, exceeded := 0, false + parsed := false + for range min(3, len(paths)) { + workers.Go(func() { + for { + select { + case <-workCtx.Done(): + return + case i, ok := <-jobs: + if !ok { + return + } + path := paths[i] + buffer := buffers[path] + var candidate *preparedFileProjection + parserOwned := false + if buffer.existsAfter { + built, projection := s.buildFileProjection(workCtx, path, buffer.content) + candidate = built + parserOwned = projection != nil + } else { + candidate = s.buildRemovalProjection(workCtx, buffer.relPath, path) + } + budgetMu.Lock() + parsed = parsed || parserOwned + if candidate != nil && candidate.entry != nil { + preparedBytes += candidate.entry.size + projectedFiles++ + if projectedFiles > projectionMaxFiles || preparedBytes > projectionMaxBytes { + exceeded = true + cancel() + } + } + if !exceeded && workCtx.Err() == nil { + results[i] = candidate + } + budgetMu.Unlock() + } + } + }) + } +feed: + for i := range paths { + select { + case jobs <- i: + case <-workCtx.Done(): + break feed + } + } + close(jobs) + workers.Wait() // no cohort worker outlives cancellation or a cap refusal + lap.applicable = parsed + lap.finish() // baseline/delta metadata is deliberately outside parse WALL + if parsed { + s.noteAcknowledgementParser(ctx, "source_projection_parse_gate") + } else { + s.noteAcknowledgementParser(ctx, "not_applicable_text_splice") + } + if exceeded || workCtx.Err() != nil { + return nil + } + prepared := make(map[string]*preparedFileProjection, len(paths)) + preparedBytes = 0 + for i, path := range paths { + if ctx.Err() != nil { + return nil + } + buffer := buffers[path] + candidate := results[i] + s.prepareFileViewEvidence(ctx, candidate, &plannedSource{path: path, before: buffer.original, existed: buffer.existsBefore}, buffer.content) + if candidate != nil && candidate.entry != nil { + preparedBytes += candidate.entry.size + if preparedBytes > projectionMaxBytes { + return nil + } + } + prepared[path] = results[i] // stable original frontier order, independent of workers + } + return prepared +} + +// Called only after every forward effect and directory sync has succeeded, +// while the original physical union is still held. No sequence enters the +// durable receipt/journal; recovery cannot invent this process-local origin. +func (s *Server) publishBatchProjections(ctx context.Context, state *batchTransactionState, receipt batchTransactionReceipt, prepared map[string]*preparedFileProjection) { + sequence := mutationDiskCommitSequence.Add(1) + state.mu.Lock() + state.projectionCommitSeq = sequence + state.mu.Unlock() + members := verifyBatchProjectionCohort(ctx, receipt, prepared, sequence, s.verifyPreparedProjection) + _ = pendingFileProjections.publishCohort(members) // cache refusal never changes disk authority +} + +// One deadline covers the complete readback frontier, never the durable writer. +func verifyBatchProjectionCohort(ctx context.Context, receipt batchTransactionReceipt, prepared map[string]*preparedFileProjection, sequence uint64, verify func(context.Context, *preparedFileProjection, uint64, string) (*projectionEntry, error)) []projectionCohortMember { + verifyCtx, cancel := context.WithTimeout(ctx, 150*time.Millisecond) + defer cancel() + members := make([]projectionCohortMember, 0, len(receipt.Files)) + for _, file := range receipt.Files { + candidate := prepared[file.Path] + if candidate == nil || candidate.entry == nil { + continue + } + entry, err := verify(verifyCtx, candidate, sequence, file.AfterSHA256) + if err != nil || verifyCtx.Err() != nil { + return nil + } + members = append(members, projectionCohortMember{Entry: entry, Expected: candidate.expected}) + } + return members +} + +type batchProjectionFileView struct { + Path string `json:"path"` + ContentSHA256 string `json:"content_sha256,omitempty"` + ReadbackExact bool `json:"readback_exact"` + FileView mutationFileView `json:"file_view"` + GraphStatusHint string `json:"graph_status_hint"` +} + +type batchProjectionResponse struct { + batchAcknowledgementResponse + CommitSeq uint64 `json:"commit_seq,omitempty"` + FileViews []batchProjectionFileView `json:"file_views,omitempty"` +} + +func (s *Server) batchProjectionResponse(receipt batchTransactionReceipt) batchProjectionResponse { + response := batchProjectionResponse{batchAcknowledgementResponse: s.batchAcknowledgementResponse(receipt)} + if value, ok := s.batchTransactions.Load(receipt.TransactionID); ok { + if state, ok := value.(*batchTransactionState); ok { + state.mu.RLock() + response.CommitSeq = state.projectionCommitSeq + state.mu.RUnlock() + } + } + if receipt.Status != "committed" || receipt.DiskStatus != "committed" { + return response + } + for _, file := range receipt.Files { + view := batchProjectionFileView{Path: file.Path, ContentSHA256: file.AfterSHA256, + FileView: mutationFileView{State: "unavailable", Reason: "projection_not_live", DeltaState: "unavailable", DeltaReason: "projection_not_live", SyntaxHealth: fileViewSyntaxHealth{State: "unknown"}}} + key := pathkey.MutationKey(file.Path) + entry := pendingFileProjections.snap.Load().entries[key] + if response.CommitSeq != 0 && entry != nil && entry.commitSeq == response.CommitSeq && + entry.contentSHA256 == file.AfterSHA256 && entry.tombstone == file.AfterAbsent && liveProjectionForAcknowledgement(entry) { + view.ReadbackExact, view.FileView = true, fileViewForEntry(entry) + } + view.GraphStatusHint = mutationFileGraphHint(map[string]any{"graph_status": receipt.GraphStatus}, view.ReadbackExact) + response.FileViews = append(response.FileViews, view) + } + return response +} diff --git a/internal/mcp/batch_projection_budget_test.go b/internal/mcp/batch_projection_budget_test.go new file mode 100644 index 000000000..4a9444fb4 --- /dev/null +++ b/internal/mcp/batch_projection_budget_test.go @@ -0,0 +1,115 @@ +package mcp + +import ( + "context" + "path/filepath" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +func TestBatchProjectionVerificationSharesOneDeadline(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + prepared := map[string]*preparedFileProjection{ + "a": {entry: &projectionEntry{}}, "b": {entry: &projectionEntry{}}, "c": {entry: &projectionEntry{}}, + } + receipt := batchTransactionReceipt{Files: []batchTransactionFile{{Path: "a"}, {Path: "b"}, {Path: "c"}}} + calls := 0 + start := time.Now() + members := verifyBatchProjectionCohort(t.Context(), receipt, prepared, 7, func(ctx context.Context, p *preparedFileProjection, seq uint64, sha string) (*projectionEntry, error) { + calls++ + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.Equal(t, start.Add(150*time.Millisecond), deadline) + select { + case <-time.After(100 * time.Millisecond): + return p.entry, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }) + require.Nil(t, members, "deadline cannot publish the verified prefix alone") + require.Equal(t, 2, calls, "no fresh budget for later members") + require.Equal(t, 150*time.Millisecond, time.Since(start)) + }) +} + +type batchProjectionOverlapExtractor struct { + parser.Extractor + active atomic.Bool + entered chan struct{} + release chan struct{} + finished chan struct{} +} + +func (e *batchProjectionOverlapExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult, error) { + if e.active.Load() { + e.entered <- struct{}{} + <-e.release + defer func() { e.finished <- struct{}{} }() + } + return e.Extractor.Extract(path, raw) +} + +func TestBatchProjectionPreparationOverlapsRealExtractions(t *testing.T) { + extractor := &batchProjectionOverlapExtractor{Extractor: languages.NewGoExtractor(), entered: make(chan struct{}, 2), release: make(chan struct{}), finished: make(chan struct{}, 2)} + registry := parser.NewRegistry() + registry.Register(extractor) + s, root := setupTestServerWithRegistry(t, registry) + a, b := filepath.Join(root, "main.go"), filepath.Join(root, "other.go") + ctx, ack, _ := s.withEditAcknowledgement(t.Context()) + ctx, unlock, err := acquireMutationPaths(ctx, []string{a, b}) + require.NoError(t, err) + defer unlock() + buffers := map[string]*batchFileBuffer{ + a: {absPath: a, relPath: "main.go", original: []byte("package main\nfunc helper() {}\n"), content: []byte("package main\nfunc ParallelFirst() {}\n"), existsBefore: true, existsAfter: true}, + b: {absPath: b, relPath: "other.go", content: []byte("package main\nfunc ParallelSecond() {}\n"), existsAfter: true}, + } + extractor.active.Store(true) + released := false + defer func() { + if !released { + close(extractor.release) + } + }() + result := make(chan map[string]*preparedFileProjection, 1) + go func() { result <- s.prepareBatchProjections(ctx, buffers, []string{a, b}) }() + for range 2 { + select { + case <-extractor.entered: + case <-time.After(2 * time.Second): + t.Fatal("both native extractions did not enter") + } + } + close(extractor.release) + released = true + var got map[string]*preparedFileProjection + select { + case got = <-result: + case <-time.After(2 * time.Second): + t.Fatal("batch preparation did not join") + } + for range 2 { + select { + case <-extractor.finished: + case <-time.After(2 * time.Second): + t.Fatal("owned native extraction did not finish") + } + } + require.NotNil(t, got[a]) + require.NotNil(t, got[b]) + require.NotNil(t, got[a].entry, "a serial build expired before its sibling could enter") + require.NotNil(t, got[b].entry) + ack.mu.Lock() + defer ack.mu.Unlock() + require.EqualValues(t, 1, ack.laps.data[editAckParseGate].started) + require.EqualValues(t, 1, ack.laps.data[editAckParseGate].completed) + require.False(t, ack.laps.overlap, "concurrent files share one wall lap, never nested per-file laps") + require.False(t, ack.laps.data[editAckParseGate].invalid) + require.Equal(t, "source_projection_parse_gate", ack.parserScope) +} diff --git a/internal/mcp/batch_transaction.go b/internal/mcp/batch_transaction.go index cdf735cc7..f2b8242a4 100644 --- a/internal/mcp/batch_transaction.go +++ b/internal/mcp/batch_transaction.go @@ -16,6 +16,7 @@ import ( "time" "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" "github.com/zzet/gortex/internal/pathkey" @@ -64,6 +65,7 @@ type batchTransactionFile struct { } type batchTransactionReceipt struct { + AckMode string `json:"ack_mode,omitempty"` Version int `json:"version"` CheckoutGeneration int64 `json:"checkout_generation,omitempty"` HeadRef string `json:"checkout_head_ref,omitempty"` @@ -98,6 +100,8 @@ type batchTransactionState struct { // A scalar reference only: the bounded mutation ledger owns timing data. // This is neither durable journal metadata nor a pointer to an RPC history. acknowledgementReceipt string + // Scalar only; never persisted or reconstructed from durable journal state. + projectionCommitSeq uint64 } // cloneBatchReceipt returns a receipt that shares no slice or map storage with @@ -1068,7 +1072,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, paths = append(paths, plan.destinationPath) } } - release, lockErr := acquireMutationPaths(ctx, paths) + ctx, release, lockErr := acquireMutationPaths(ctx, paths) if lockErr != nil { receipt.Results = batchFailureResults(plans, 0, "edit cancelled while waiting for exclusive file access: "+lockErr.Error()) receipt.Summary = batchSummary(receipt.Results) @@ -1093,6 +1097,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, if applyErr != nil { return s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", applyErr.Error()), nil } + preparedProjections := s.prepareBatchProjections(ctx, buffers, orderedPaths) if err := s.prepareBatchJournal(&receipt, buffers, orderedPaths); err != nil { return s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", "could not persist transaction journal: "+err.Error()), nil } @@ -1120,18 +1125,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, // Commit is deliberately non-cancellable. Once the first rename succeeds, // every remaining write or rollback must run to a terminal disk state. - writer := s.batchDurability().writeFile - remover := s.batchDurability().removeFile - if s.batchWriteOverride != nil { - // Preserve the target-only fault-injection seam used by commit tests; - // journal and rollback writes always retain the durability discipline. - writer = s.batchWriteOverride - } - if s.batchRemoveOverride != nil { - remover = s.batchRemoveOverride - } fileObservation = s.beginBatchFileObservation(ctx, receipt, buffers, orderedPaths) - writer = s.observedBatchFileWriter(fileObservation, writer) finishCommitFailure := func(failedPath, message string) batchTransactionReceipt { status, rollbackErr := s.rollbackBatchReceipt(receipt, ctx) if rollbackErr != nil { @@ -1161,10 +1155,10 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, if targetErr := s.validateBatchCreateTarget(ctx, path, buffer.relPath); targetErr != nil { commitErr = targetErr } else { - commitErr = s.writeAcknowledgedBatchFile(ctx, writer, path, buffer.content, buffer.mode) + _, commitErr = s.commitFileMutation(ctx, "batch_edit", "", "", buffer.relPath, path, buffer.content, buffer.mode, sourceMutationOperation{kind: batchForwardWrite, observation: fileObservation}) } } else { - commitErr = s.writeAcknowledgedBatchFile(ctx, writer, path, buffer.content, buffer.mode) + _, commitErr = s.commitFileMutation(ctx, "batch_edit", "", "", buffer.relPath, path, buffer.content, buffer.mode, sourceMutationOperation{kind: batchForwardWrite, observation: fileObservation}) } if commitErr != nil { message := fmt.Sprintf("could not commit %s: %v", buffer.relPath, commitErr) @@ -1180,7 +1174,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, if err := guardCheckoutMutationPath(ctx, path); err != nil { return finishCommitFailure(buffer.relPath, err.Error()), nil } - if commitErr := s.removeAcknowledgedBatchFile(ctx, remover, path); commitErr != nil { + if _, commitErr := s.commitFileMutation(ctx, "batch_edit", "", "", buffer.relPath, path, nil, 0, sourceMutationOperation{kind: batchForwardRemove}); commitErr != nil { message := fmt.Sprintf("could not commit %s: %v", buffer.relPath, commitErr) return finishCommitFailure(buffer.relPath, message), nil } @@ -1202,6 +1196,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, if persistErr := s.persistBatchManifest(receipt); persistErr != nil { receipt.Error = "disk committed; terminal journal update failed: " + persistErr.Error() } + s.publishBatchProjections(context.WithoutCancel(ctx), state, receipt, preparedProjections) state.publish(receipt, false) s.finishBatchFileObservation(fileObservation, receipt) @@ -1215,7 +1210,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, session.recordSymbol(plan.edit.SymbolID) } } - s.refreshBatchGraph(ctx, state) + s.refreshBatchGraphReleasing(ctx, state, release) return state.snapshot(), nil } @@ -1266,7 +1261,16 @@ func (s *Server) batchTransactionStatus(ctx context.Context, transactionID strin return state.snapshot(), nil } +// Compatibility entry for callers which do not own a source frontier. func (s *Server) beginBatchGraphRefresh(observationCtx context.Context, absPath string, transaction batchTransactionReceipt) mutationReindexOutcome { + admission := s.admitBatchGraphRefresh(observationCtx, absPath, transaction) + if admission.inlinePath != "" { + return mutationReindexOutcome{Reindexed: s.reindexFile(admission.inlinePath)} + } + return admission.immediate +} + +func (s *Server) admitBatchGraphRefresh(observationCtx context.Context, absPath string, transaction batchTransactionReceipt) sourceMutationAdmission { ctx := context.Background() // preserve the original scheduler's lifetime if watcher := s.currentWatcher(); watcher != nil { if scheduler, ok := watcher.(mutationScheduler); ok { @@ -1282,17 +1286,17 @@ func (s *Server) beginBatchGraphRefresh(observationCtx context.Context, absPath ticket, err := scheduler.EnqueueToolMutation(indexer.WithPublicationRecord(ctx, publication), absPath) lap.finish() if err != nil { - return mutationReindexOutcome{Err: err} + return sourceMutationAdmission{immediate: mutationReindexOutcome{Err: err}} } if ticket != nil { receipt := s.trackMutationTicket(ticket) s.bindBatchFilePublication(record, receipt.outcome(true)) s.bindReadRepairAdmission(absPath, ticket.PublicationRecord, receipt) - return receipt.outcome(true) + return sourceMutationAdmission{immediate: receipt.outcome(true)} } } } - return mutationReindexOutcome{Reindexed: s.reindexFile(absPath)} + return sourceMutationAdmission{inlinePath: absPath} } // batchReceiptsPublished closes once every tracked receipt of files has @@ -1390,6 +1394,13 @@ func (s *Server) waitBatchGraphReceipts(ctx context.Context, files []batchTransa } func (s *Server) refreshBatchGraph(ctx context.Context, state *batchTransactionState) { + s.refreshBatchGraphReleasing(ctx, state, func() {}) +} + +// Original forward path ownership ends only after the full durable frontier +// has been admitted. Publication/inline compatibility work cannot retain it. +func (s *Server) refreshBatchGraphReleasing(ctx context.Context, state *batchTransactionState, releasePaths func()) { + defer releasePaths() // Acquire checkout authority before graphMu, matching the initial commit // and prepared recovery. Waiting for a lease while holding graphMu would // deadlock a committer that owns that lease and is admitting its ticket. @@ -1424,6 +1435,7 @@ func (s *Server) refreshBatchGraph(ctx context.Context, state *batchTransactionS return } outcomes := make(map[string]mutationReindexOutcome, len(receipt.Files)) + var inlinePaths []string // A durable transaction readmits its complete frontier after any receipt loss. // Live receipt sets retain their original status and generation identities. needsAdmission := false @@ -1482,15 +1494,39 @@ func (s *Server) refreshBatchGraph(ctx context.Context, state *batchTransactionS file.ReindexReceipt = "" file.ReindexGeneration = 0 } - outcome := s.beginBatchGraphRefresh(ctx, file.Path, receipt) + admission := s.admitBatchGraphRefresh(ctx, file.Path, receipt) + if admission.inlinePath != "" { + inlinePaths = append(inlinePaths, admission.inlinePath) + } + outcome := admission.immediate outcomes[file.Path] = outcome file.ReindexReceipt = outcome.Receipt file.ReindexGeneration = outcome.Generation } + releasePaths() + for _, path := range inlinePaths { + outcomes[path] = mutationReindexOutcome{Reindexed: s.reindexFile(path)} + } + // Admit the entire file set before waiting. The bounded wait is shared by // the batch, rather than multiplied by the number of files. - s.waitBatchGraphReceipts(ctx, receipt.Files) + policy := s.sourceAckPolicy(ctx) + // A journal captures the original transaction choice; manifests from + // before the option existed retain the historical full policy. + policy.mode = config.EditAckFull + if receipt.AckMode == string(config.EditAckLocal) { + policy.mode = config.EditAckLocal + } + if policy.freshness.requireFresh { + paths := make([]string, 0, len(receipt.Files)) + for _, file := range receipt.Files { + paths = append(paths, file.Path) + } + _ = s.awaitSourceAckTier(ctx, policy, paths, s.batchReceiptsPublished(ctx, receipt.Files)) + } else if !policy.local() { + s.waitBatchGraphReceipts(ctx, receipt.Files) + } graphStatus := "fresh" for i := range receipt.Files { file := &receipt.Files[i] @@ -1571,17 +1607,17 @@ func (s *Server) finishBatchTransaction(state *batchTransactionState, receipt ba } func (s *Server) handleAtomicBatchEdit(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - s.noteAcknowledgementParser(ctx, "not_applicable_text_splice") args := req.GetArguments() transactionID, _ := args["transaction_id"].(string) statusOnly, _ := args["status_only"].(bool) rawEdits, hasEdits := args["edits"] if statusOnly || !hasEdits || rawEdits == nil { + s.noteAcknowledgementParser(ctx, "not_applicable_text_splice") receipt, err := s.batchTransactionStatus(ctx, transactionID) if err != nil { return mcp.NewToolResultError(err.Error()), nil } - response := s.batchAcknowledgementResponse(receipt) + response := s.batchProjectionResponse(receipt) if isCompact(req) { return mcp.NewToolResultText(fmt.Sprintf("%s %s disk=%s graph=%s\n", receipt.TransactionID, receipt.Status, receipt.DiskStatus, receipt.GraphStatus) + response.referenceLine()), nil } @@ -1596,6 +1632,7 @@ func (s *Server) handleAtomicBatchEdit(ctx context.Context, req mcp.CallToolRequ return mcp.NewToolResultError("edits array is empty"), nil } if dryRun, _ := args["dry_run"].(bool); dryRun { + s.noteAcknowledgementParser(ctx, "not_applicable_text_splice") // Paths are resolved exactly as the commit resolves them, symbol edits // included: without that a symbol edit has no path, and the rules that // compare paths — lifecycle overlap, then aliasing — see a shorter @@ -1647,7 +1684,7 @@ func (s *Server) handleAtomicBatchEdit(ctx context.Context, req mcp.CallToolRequ if err != nil { return mcp.NewToolResultError(err.Error()), nil } - response := s.batchAcknowledgementResponse(receipt) + response := s.batchProjectionResponse(receipt) if isCompact(req) { var out strings.Builder fmt.Fprintf(&out, "%s %s disk=%s graph=%s\n", receipt.TransactionID, receipt.Status, receipt.DiskStatus, receipt.GraphStatus) diff --git a/internal/mcp/batch_transaction_journal.go b/internal/mcp/batch_transaction_journal.go index 7bff2877a..3acdbb6be 100644 --- a/internal/mcp/batch_transaction_journal.go +++ b/internal/mcp/batch_transaction_journal.go @@ -13,6 +13,7 @@ import ( "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/gitstate" + "github.com/zzet/gortex/internal/pathkey" ) const batchTransactionDirEnv = "GORTEX_BATCH_TRANSACTION_DIR" @@ -153,6 +154,7 @@ func (s *Server) loadOrCreateBatchTransaction(ctx context.Context, transactionID return nil, "", fmt.Errorf("transaction %q not found", transactionID) } receipt := batchTransactionReceipt{ + AckMode: string(s.sourceAckPolicy(ctx).mode), Version: batchTransactionVersion, TransactionID: transactionID, Fingerprint: fingerprint, Status: "preparing", DiskStatus: "unchanged", GraphStatus: "not_started", StartedAt: time.Now().UTC(), } @@ -273,12 +275,16 @@ func classifyBatchFiles(files []batchTransactionFile) (before, after, unknown [] return before, after, unknown, err } -func (s *Server) rollbackBatchReceipt(receipt batchTransactionReceipt, contexts ...context.Context) (string, error) { - for _, ctx := range contexts { - for _, file := range receipt.Files { - if err := guardCheckoutMutationPath(ctx, file.Path); err != nil { - return "recovery_conflict", err - } +func (s *Server) rollbackBatchReceipt(receipt batchTransactionReceipt, ctx context.Context) (string, error) { + // Prove the entire original recovery set before restoring any file. The + // transaction still owns rollback and durability; no per-file ordinary + // commit record may advertise a partial restore as source authority. + for _, file := range receipt.Files { + if !mutationPathAdmitted(ctx, pathkey.MutationKey(file.Path)) { + return "recovery_conflict", fmt.Errorf("%w: rollback source path is not held for mutation", errMutationNotApplied) + } + if err := guardCheckoutMutationPath(ctx, file.Path); err != nil { + return "recovery_conflict", err } } _, after, unknown, classifyErr := classifyBatchFiles(receipt.Files) @@ -287,7 +293,7 @@ func (s *Server) rollbackBatchReceipt(receipt batchTransactionReceipt, contexts } for _, file := range after { if file.BeforeAbsent { - if err := s.batchDurability().removeFile(file.Path); err != nil && !errors.Is(err, os.ErrNotExist) { + if _, err := s.commitFileMutation(ctx, "batch_edit", "", "", file.RelativePath, file.Path, nil, 0, sourceMutationOperation{kind: batchRestoreRemove}); err != nil && !errors.Is(err, os.ErrNotExist) { return "recovery_conflict", fmt.Errorf("remove rollback-created %s: %w", file.RelativePath, err) } continue @@ -296,7 +302,7 @@ func (s *Server) rollbackBatchReceipt(receipt batchTransactionReceipt, contexts if err != nil { return "recovery_conflict", fmt.Errorf("load rollback backup for %s: %w", file.RelativePath, err) } - if err := s.batchDurability().writeFile(file.Path, backup, file.Mode); err != nil { + if _, err := s.commitFileMutation(ctx, "batch_edit", "", "", file.RelativePath, file.Path, backup, file.Mode, sourceMutationOperation{kind: batchRestoreWrite}); err != nil { return "recovery_conflict", fmt.Errorf("restore %s: %w", file.RelativePath, err) } } @@ -340,7 +346,7 @@ func (s *Server) recoverBatchTransaction(ctx context.Context, state *batchTransa } recoveryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() - release, err := acquireMutationPaths(recoveryCtx, paths) + recoveryCtx, release, err := acquireMutationPaths(recoveryCtx, paths) if err != nil { s.finishBatchTransaction(state, receipt, "recovery_conflict", "conflict", "not_started", "could not acquire recovery locks: "+err.Error()) return @@ -374,12 +380,12 @@ func (s *Server) recoverBatchTransaction(ctx context.Context, state *batchTransa receipt.Recovered = true s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", "recovered prepared transaction before commit") default: - if err := prepareBatchRecovery(ctx); err != nil { + if err := prepareBatchRecovery(recoveryCtx); err != nil { receipt.Error = err.Error() state.publish(receipt, true) return } - status, rollbackErr := s.rollbackBatchReceipt(receipt, ctx) + status, rollbackErr := s.rollbackBatchReceipt(receipt, recoveryCtx) receipt.Recovered = true message := "recovered interrupted mixed commit by restoring original bytes" if rollbackErr != nil { diff --git a/internal/mcp/checkout_binding.go b/internal/mcp/checkout_binding.go index 9e88a9aa8..b0a613779 100644 --- a/internal/mcp/checkout_binding.go +++ b/internal/mcp/checkout_binding.go @@ -15,20 +15,21 @@ import ( "github.com/zzet/gortex/internal/pathkey" ) -// The three request-level freshness knobs. They are arguments of the request +// Request-level freshness knobs. They are arguments of the request // rather than of any one tool — the middleware reads them for every tool, no // handler ever does — so they are named once here. const ( - requireExactArgName = "require_exact" - requireFreshArgName = "require_fresh" - waitDeadlineArgName = "wait_deadline" + requireExactArgName = "require_exact" + requireFreshArgName = "require_fresh" + waitDeadlineArgName = "wait_deadline" + freshnessScopeArgName = "freshness_scope" ) // requestFreshnessArgNames is the accepted set, in the order the guide states // them. -var requestFreshnessArgNames = []string{requireExactArgName, requireFreshArgName, waitDeadlineArgName} +var requestFreshnessArgNames = []string{requireExactArgName, requireFreshArgName, waitDeadlineArgName, freshnessScopeArgName} -// requestFreshnessArgKeys is THE allowlist that makes the three knobs callable +// requestFreshnessArgKeys is THE allowlist that makes these knobs callable // on every tool. It is the single place the set is enumerated for dispatch: // wrapToolArgGuard consults it (arg_schema_guard.go) so a guarded tool's closed // schema cannot refuse a knob the request middleware honours, and the tests @@ -104,6 +105,7 @@ type requestFreshness struct { // requireFresh asks for a bounded wait until the selected checkout route // reflects the working copy as it is now. requireFresh bool + scope string // deadline bounds that wait. It is absolute (RFC3339) so a retry carries // the same ceiling rather than restarting the clock. deadline time.Time @@ -154,7 +156,7 @@ func (f requestFreshness) effectiveDeadline(now time.Time, ctx context.Context) return deadline } -// takeRequestFreshness parses the three request-level knobs. +// takeRequestFreshness parses the request-level knobs. // // It deliberately does not strip them from the argument map: require_exact has // always travelled through to the handler and every tool ignores an argument @@ -173,6 +175,15 @@ func takeRequestFreshness(req *mcp.CallToolRequest) requestFreshness { requireExact: requestRequiresExactCheckoutView(req), requireFresh: requestBoolArg(req, requireFreshArgName), } + if raw, present := requestRawArg(req, freshnessScopeArgName); present { + value, ok := raw.(string) + if !ok || value != "file" && value != "relations" && value != "derived" { + freshness.err = graphview.NewViewError(graphview.CodeInvalidViewSelector, + "freshness_scope must be file, relations, or derived") + return freshness + } + freshness.scope = value + } raw, present := requestRawArg(req, waitDeadlineArgName) if !present || raw == nil { return freshness diff --git a/internal/mcp/checkout_mutation_receipt_test.go b/internal/mcp/checkout_mutation_receipt_test.go index 89a78996b..a7c88ea13 100644 --- a/internal/mcp/checkout_mutation_receipt_test.go +++ b/internal/mcp/checkout_mutation_receipt_test.go @@ -278,7 +278,7 @@ func TestCheckoutMutationReceiptBindsBytesActuallyCommitted(t *testing.T) { s := &Server{} ctx := withCheckoutMutation(context.Background(), mutation, filepath.Dir(path)) data := []byte("package committed\n") - record, err := s.commitFileMutation(ctx, "write_file", "", "", "edit.go", path, data, 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "edit.go", path, data, 0o600) if err != nil { t.Fatal(err) } diff --git a/internal/mcp/checkout_precommit_refusal_test.go b/internal/mcp/checkout_precommit_refusal_test.go index 0194f9b78..15041bfe6 100644 --- a/internal/mcp/checkout_precommit_refusal_test.go +++ b/internal/mcp/checkout_precommit_refusal_test.go @@ -109,7 +109,7 @@ func TestCheckoutPrecommitRefusalExcludesMultiFileAndAppliedScopes(t *testing.T) require.NoError(t, os.WriteFile(state.committedPath, []byte("applied earlier\n"), 0o644)) } f.srv.mutationPreCommitHook = func(*mutationCommitRecord) { movePrecommitHEAD(t, f) } - record, err := f.srv.commitFileMutation(ctx, tc.tool, "", "", "edit.go", filepath.Join(f.worktree, "edit.go"), []byte("package repo\n"), 0o644) + record, err := f.srv.commitHeldFileMutationForTest(t, ctx, tc.tool, "", "", "edit.go", filepath.Join(f.worktree, "edit.go"), []byte("package repo\n"), 0o644) require.ErrorIs(t, err, indexer.ErrCheckoutMutationStale) require.ErrorIs(t, err, errMutationNotApplied) require.Equal(t, mutationDiskNotApplied, record.diskStatus()) @@ -154,7 +154,7 @@ func TestCheckoutPrecommitRefusalRejectsUnprovenErrors(t *testing.T) { lease := &primitiveCheckoutMutation{prepareErr: cause} ctx := withCheckoutMutation(t.Context(), lease, f.worktree) checkoutMutationFromContext(ctx).singleFile = true - record, err := f.srv.commitFileMutation(ctx, "edit_file", "", "", "edit.go", filepath.Join(f.worktree, "edit.go"), []byte("package repo\n"), 0o644) + record, err := f.srv.commitHeldFileMutationForTest(t, ctx, "edit_file", "", "", "edit.go", filepath.Join(f.worktree, "edit.go"), []byte("package repo\n"), 0o644) require.ErrorIs(t, err, cause) require.Equal(t, mutationDiskNotApplied, record.diskStatus()) require.NotContains(t, mutationNotAppliedMessage("edit", record, err), "Native read/edit is allowed") @@ -173,7 +173,7 @@ func TestCheckoutPrecommitRefusalDoesNotExtendToEncodingIOOrPostwrite(t *testing lease := &primitiveCheckoutMutation{prepareErr: indexer.ErrCheckoutMutationStale} ctx := withCheckoutMutation(t.Context(), lease, f.worktree) checkoutMutationFromContext(ctx).singleFile = true - record, err := f.srv.commitFileMutation(ctx, "write_file", "", "", "utf16.go", path, []byte("package repo\n"), 0o644) + record, err := f.srv.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "utf16.go", path, []byte("package repo\n"), 0o644) require.ErrorContains(t, err, "UTF-16") require.Zero(t, lease.prepared, "encoding refusal must not borrow a lease refusal") require.Equal(t, mutationDiskNotApplied, record.diskStatus()) @@ -187,7 +187,7 @@ func TestCheckoutPrecommitRefusalDoesNotExtendToEncodingIOOrPostwrite(t *testing lease := &primitiveCheckoutMutation{} ctx := withCheckoutMutation(t.Context(), lease, f.worktree) checkoutMutationFromContext(ctx).singleFile = true - record, err := f.srv.commitFileMutation(ctx, "write_file", "", "", "directory.go", path, []byte("package repo\n"), 0o644) + record, err := f.srv.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "directory.go", path, []byte("package repo\n"), 0o644) require.Error(t, err) require.Equal(t, 1, lease.prepared) require.Equal(t, mutationDiskFailed, record.diskStatus()) @@ -201,7 +201,7 @@ func TestCheckoutPrecommitRefusalDoesNotExtendToEncodingIOOrPostwrite(t *testing state := checkoutMutationFromContext(ctx) state.singleFile = true data := []byte("package repo\n") - record, err := f.srv.commitFileMutation(ctx, "write_file", "", "", "postwrite.go", path, data, 0o644) + record, err := f.srv.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "postwrite.go", path, data, 0o644) require.NoError(t, err) require.Equal(t, mutationDiskCommitted, record.diskStatus()) outcome := f.srv.refreshCheckoutMutation(ctx, path, state) diff --git a/internal/mcp/concurrent_symbol_readback_acceptance_test.go b/internal/mcp/concurrent_symbol_readback_acceptance_test.go new file mode 100644 index 000000000..4072d1267 --- /dev/null +++ b/internal/mcp/concurrent_symbol_readback_acceptance_test.go @@ -0,0 +1,187 @@ +//go:build acceptance + +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" +) + +// This verifies the #1012 right-symbol write criterion with an independent disk +// oracle. Public source outcomes are a separate two-session census, never the +// #1014 1/8/32-agent acceptance row. No latency or reference-host credit. +func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + const original = "package sample\n\nfunc first() int {\n\treturn 0\n}\n\nfunc second() int {\n\treturn 0\n}\n\nfunc decoyZero() int {\n\treturn 0\n}\n\nfunc decoyFirst() int {\n\treturn 11\n}\n\nfunc decoySecond() int {\n\treturn 22\n}\n" + s, root := setupMoveInlineRepo(t, map[string]string{"sample.go": original}) + watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 5}, zap.NewNop()) + require.NoError(t, err) + s.SetWatcher(watcher) + require.NoError(t, watcher.Start([]string{root})) + t.Cleanup(func() { require.NoError(t, watcher.Stop()); s.indexer.Close() }) + // Do not change the default policy to make this control complete sooner. + require.Equal(t, config.EditAckFull, s.sourceAckPolicy(t.Context()).mode) + edit := s.wrapToolHandler(s.handleEditSymbol) + read := s.wrapToolHandler(s.handleGetSymbolSource) + var auxiliaryResponses atomic.Int64 + invoke := func(ctx context.Context, name string, args map[string]any) (map[string]any, error) { + req := mcplib.CallToolRequest{} + req.Params.Name = name + req.Params.Arguments = args + var result *mcplib.CallToolResult + var err error + if name == "edit_symbol" { + result, err = edit(ctx, req) + } else { + result, err = read(ctx, req) + } + if err != nil { + return nil, err + } + if result == nil { + return nil, fmt.Errorf("%s returned no result", name) + } + if result.IsError { + return nil, fmt.Errorf("%s refused: %s", name, resultText(result)) + } + if len(result.Content) == 0 { + return nil, fmt.Errorf("%s returned no primary payload", name) + } + primary, ok := result.Content[0].(mcplib.TextContent) + if !ok { + return nil, fmt.Errorf("%s returned a non-text primary payload", name) + } + // Session notes are separate MCP content blocks, not trailing JSON. + // Keep the real middleware enabled and decode only its primary record. + if len(result.Content) > 1 { + auxiliaryResponses.Add(1) + } + var fields map[string]any + if err := json.Unmarshal([]byte(primary.Text), &fields); err != nil { + return nil, fmt.Errorf("%s primary JSON: %w; payload=%q", name, err, primary.Text) + } + return fields, nil + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + defer cancel() + start := make(chan struct{}) + type outcome struct { + name string + completed int + sourceExact int + sourcePending int + sourceRefused int + err error + } + outcomes := make(chan outcome, 2) + var workers sync.WaitGroup + for _, name := range []string{"first", "second"} { + workers.Add(1) + go func() { + defer workers.Done() + result := outcome{name: name} + defer func() { outcomes <- result }() + session := WithSessionID(ctx, "repeat-"+name) + <-start + unique := 11 + if name == "second" { + unique = 22 + } + for ordinal := 0; ordinal < 500; ordinal++ { + before, after := 0, unique + if ordinal%2 == 1 { + before, after = unique, 0 + } + ack, err := invoke(session, "edit_symbol", map[string]any{ + "id": "sample.go::" + name, "old_source": fmt.Sprintf("\treturn %d", before), "new_source": fmt.Sprintf("\treturn %d", after), "dry_run": false, "format": "json", + }) + if err != nil { + result.err = fmt.Errorf("edit %d: %w", ordinal, err) + cancel() + return + } + if ack["disk_status"] != "committed" || ack["ack_mode"] != "full" || ack["graph_status"] != "fresh" { + result.err = fmt.Errorf("edit %d lacks actual committed/full ACK: %v", ordinal, ack) + cancel() + return + } + body, readErr := invoke(session, "get_symbol_source", map[string]any{"id": "sample.go::" + name, "context_lines": 0, "format": "json"}) + want := fmt.Sprintf("func %s() int {\n\treturn %d\n}", name, after) + switch { + case readErr != nil: + result.sourceRefused++ + case body["source"] == want: + result.sourceExact++ + case body["source"] == nil && body["source_state"] == "pending" && body["complete"] == false: + result.sourcePending++ + default: + result.err = fmt.Errorf("read %d returned an incorrect source payload: want %q response=%v", ordinal, want, body) + cancel() + return + } + // Stabilize only the independent physical oracle, AFTER the actual + // immediate public read above. This adds no wait to that probe and + // prevents a peer's temporary rename from racing the oracle's open. + path := filepath.Join(root, "sample.go") + _, release, err := acquireMutationPaths(session, []string{path}) + if err != nil { + result.err = err + cancel() + return + } + current, diskErr := os.ReadFile(path) + release() + if diskErr != nil || !strings.Contains(string(current), want) { + result.err = fmt.Errorf("disk oracle edit %d must land in %s: err=%v want=%q actual=%q", ordinal, name, diskErr, want, current) + cancel() + return + } + for _, decoy := range []string{"func decoyZero() int {\n\treturn 0\n}", "func decoyFirst() int {\n\treturn 11\n}", "func decoySecond() int {\n\treturn 22\n}"} { + if !strings.Contains(string(current), decoy) { + result.err = fmt.Errorf("edit %d changed an unrelated repeated fragment: %q", ordinal, decoy) + cancel() + return + } + } + result.completed++ + } + }() + } + close(start) + workers.Wait() + close(outcomes) + total, sourceExact, sourcePending, sourceRefused := 0, 0, 0, 0 + for result := range outcomes { + require.NoError(t, result.err, "session %s completed %d", result.name, result.completed) + require.Equal(t, 500, result.completed) + total += result.completed + sourceExact += result.sourceExact + sourcePending += result.sourcePending + sourceRefused += result.sourceRefused + } + require.Equal(t, 1000, total) + require.Equal(t, 1000, sourceExact+sourcePending+sourceRefused, "every immediate public read outcome remains accounted for") + t.Logf("#1012 physical right-symbol writes=%d/1000, default full/fresh ACKs; unrelated repeated fragments unchanged", total) + t.Logf("separate two-session public source census: exact=%d pending=%d refused=%d auxiliary_note_responses=%d; NO #1014 1/8/32-agent or latency acceptance credit", sourceExact, sourcePending, sourceRefused, auxiliaryResponses.Load()) + current, err := os.ReadFile(filepath.Join(root, "sample.go")) + require.NoError(t, err) + require.Equal(t, original, string(current), "500 alternating commits per declaration preserve both repeated fragments") + require.Equal(t, 3, strings.Count(string(current), "\treturn 0")) +} diff --git a/internal/mcp/contract_core_capability_table_test.go b/internal/mcp/contract_core_capability_table_test.go index 771f455e1..88c3131a1 100644 --- a/internal/mcp/contract_core_capability_table_test.go +++ b/internal/mcp/contract_core_capability_table_test.go @@ -232,6 +232,9 @@ func contractCoreCapabilityTable() []contractCoreCapRow { {"OverlayLayerContractFileNodesReader", capOf[graph.OverlayLayerContractFileNodesReader], capDropped, "checked physical-layer file nodes; graph.ContractFileNodesContext unwraps and composes the selected reader with its ownership masks instead of forwarding physical rows on the request wrapper"}, {"ContractStateStore", capOf[graph.ContractStateStore], capDropped, capReasonBackend}, {"FileMetaReader", capOf[graph.FileMetaReader], capDropped, capReasonBackend}, + {"FileMetaPathReader", capOf[graph.FileMetaPathReader], capDropped, "projection retirement reads durable content evidence on the backend, never a request reader"}, + {"local:FileMetasByPaths+GetFileNodes", capOf[projectionStore], capDropped, capReasonBackend}, + {"ConstantValueProjectionReader", capOf[graph.ConstantValueProjectionReader], capDropped, "selected retirement reads checked metadata through the same leased handoff; never forwarded through contract-core request readers"}, {"RepoIndexStateReader", capOf[graph.RepoIndexStateReader], capDropped, capReasonBackend}, {"StructuralIntegrityAuditor", capOf[graph.StructuralIntegrityAuditor], capDropped, capReasonBackend}, {"StructuralIntegritySnapshotter", capOf[graph.StructuralIntegritySnapshotter], capDropped, capReasonBackend}, @@ -274,7 +277,6 @@ func contractCoreUnprobedCapabilities() []contractCoreCapName { {"CloneShingleWriter", capOf[graph.CloneShingleWriter]}, {"ConfigNodeBatchEvicter", capOf[graph.ConfigNodeBatchEvicter]}, {"ConstantValueContextReader", capOf[graph.ConstantValueContextReader]}, - {"ConstantValueProjectionReader", capOf[graph.ConstantValueProjectionReader]}, {"ConstantValueReader", capOf[graph.ConstantValueReader]}, {"ConstantValueRepoReplacer", capOf[graph.ConstantValueRepoReplacer]}, {"ConstantValueWriter", capOf[graph.ConstantValueWriter]}, @@ -314,7 +316,6 @@ func contractCoreUnprobedCapabilities() []contractCoreCapName { {"FileIndexFailureReader", capOf[graph.FileIndexFailureReader]}, {"FileIndexFailureWriter", capOf[graph.FileIndexFailureWriter]}, {"FileLanguageNodeSequencer", capOf[graph.FileLanguageNodeSequencer]}, - {"FileMetaPathReader", capOf[graph.FileMetaPathReader]}, {"FileMetaRepoReplacer", capOf[graph.FileMetaRepoReplacer]}, {"FileMetaWriter", capOf[graph.FileMetaWriter]}, {"FileMtimeDeleter", capOf[graph.FileMtimeDeleter]}, diff --git a/internal/mcp/dispatch_slot_release_test.go b/internal/mcp/dispatch_slot_release_test.go index 3dd812323..d744c6d2d 100644 --- a/internal/mcp/dispatch_slot_release_test.go +++ b/internal/mcp/dispatch_slot_release_test.go @@ -128,6 +128,13 @@ func TestMutationReindexStateReturnsTheDispatchSlotOnceItOnlyWaits(t *testing.T) require.NotEmpty(t, outcomes[1].Receipt) outcomes[0].Receipt, outcomes[1].Receipt = "", "" } + // These are independent admissions on separate servers. Their private + // publication identities must survive even when full ACK omits the + // public pending receipt; compare the answer after checking identity. + require.NotEmpty(t, outcomes[0].publicationReceipt) + require.NotEmpty(t, outcomes[1].publicationReceipt) + require.NotEqual(t, outcomes[0].publicationReceipt, outcomes[1].publicationReceipt) + outcomes[0].publicationReceipt, outcomes[1].publicationReceipt = "", "" require.Equal(t, outcomes[1], outcomes[0], "giving the slot back leaves the edit's answer unchanged") }) } @@ -319,7 +326,14 @@ func TestRenameReturnsTheDispatchSlotOnlyAtItsLastFile(t *testing.T) { return true } - results := s.commitRenameWrites(context.Background(), writes) + paths := make([]string, len(writes)) + for i, w := range writes { + paths[i] = w.AbsPath + } + heldCtx, releasePaths, err := acquireMutationPaths(context.Background(), paths) + require.NoError(t, err) + defer releasePaths() + results := s.commitRenameWrites(heldCtx, writes, releasePaths) require.Len(t, results, len(writes)) for _, result := range results { require.Equal(t, "applied", result["status"]) diff --git a/internal/mcp/edit_acknowledgement_envelope_test.go b/internal/mcp/edit_acknowledgement_envelope_test.go index 977dbd1c9..5d92d3b14 100644 --- a/internal/mcp/edit_acknowledgement_envelope_test.go +++ b/internal/mcp/edit_acknowledgement_envelope_test.go @@ -123,14 +123,14 @@ func TestAcknowledgementEnvelopeActualPreLeafRefusal(t *testing.T) { s.ToolCallTimeout = -1 var a *editAcknowledgement wrapped := s.wrapToolHandler(func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { - t.Error("invalid freshness input must refuse before the leaf") + t.Error("invalid view input must refuse before the leaf") return mcp.NewToolResultText("unexpected"), nil }) capture := s.boundToolHandler(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { a = editAcknowledgementFrom(ctx, s) return wrapped(ctx, req) }) - result, err := capture(t.Context(), writeFileRequest(map[string]any{"wait_deadline": "invalid-duration"})) + result, err := capture(t.Context(), writeFileRequest(map[string]any{"view": map[string]any{"kind": "invalid-view"}})) require.NoError(t, err) require.True(t, result.IsError) snapshot := a.snapshot() @@ -223,3 +223,20 @@ func TestAcknowledgementEnvelopeUnobservedReadsHaveNoIdentityOrAllocation(t *tes require.Zero(t, allocations, "nil observer must not allocate an envelope/token/closure") require.Equal(t, before, editAcknowledgementSequence.Load()) } + +// ACK policy captures freshness before the measured handler. Invalid policy +// input must stop there, while a later middleware refusal is measured above. +func TestAcknowledgementFreshnessPolicyRefusesBeforeMeasuredHandler(t *testing.T) { + s := fastPathTestServer(t) + s.ToolCallTimeout = -1 + entered := false + handler := s.boundToolHandler(func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + entered = true + return mcp.NewToolResultText("unexpected"), nil + }) + result, err := handler(t.Context(), writeFileRequest(map[string]any{"wait_deadline": "invalid-duration"})) + require.NoError(t, err) + require.True(t, result.IsError) + require.False(t, entered, "malformed policy must not invoke any mutation handler") + require.Contains(t, result.Content[0].(mcp.TextContent).Text, "wait_deadline") +} diff --git a/internal/mcp/edit_acknowledgement_laps_test.go b/internal/mcp/edit_acknowledgement_laps_test.go index eb5b0ba8a..6805811b8 100644 --- a/internal/mcp/edit_acknowledgement_laps_test.go +++ b/internal/mcp/edit_acknowledgement_laps_test.go @@ -143,7 +143,7 @@ func TestEditAcknowledgementRealDiskAndCanonicalTicketBoundaries(t *testing.T) { var record *mutationCommitRecord h := s.boundToolHandler(func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { var err error - record, err = s.commitFileMutation(ctx, "write_file", "", "", "written.go", path, []byte("package p\n"), 0o600) + record, err = s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "written.go", path, []byte("package p\n"), 0o600) if err != nil { return nil, err } @@ -250,7 +250,7 @@ func TestEditAcknowledgementActualDiskFailureDoesNotInventAdmission(t *testing.T var record *mutationCommitRecord h := s.boundToolHandler(func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { var err error - record, err = s.commitFileMutation(ctx, "write_file", "", "", "owned-directory", path, []byte("after"), 0o600) + record, err = s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "owned-directory", path, []byte("after"), 0o600) return nil, err }) result, err := h(canonicalTimingContext(), editOperationRequest("write_file", "")) diff --git a/internal/mcp/edit_acknowledgement_tool_boundaries_test.go b/internal/mcp/edit_acknowledgement_tool_boundaries_test.go index 5042a260c..f2d55f9a8 100644 --- a/internal/mcp/edit_acknowledgement_tool_boundaries_test.go +++ b/internal/mcp/edit_acknowledgement_tool_boundaries_test.go @@ -153,7 +153,7 @@ func TestAcknowledgementMoveRealParserAndSourceDurability(t *testing.T) { require.GreaterOrEqual(t, snapshot.ParseGate.Completed, uint64(2), "real source and existing-target Go parser calls") require.NotNil(t, snapshot.ParseGate.AckMS) require.True(t, snapshot.LapsDisjoint) - require.Equal(t, "explicit_go_parser_calls_formatting_unattributed", snapshot.ParserScope) + require.Equal(t, "mixed_observed_parser_paths", snapshot.ParserScope, "explicit Go syntax calls and the committed file projection are both observed") require.Zero(t, snapshot.TicketAdmission.Started, "synchronous move reindex is not a queue admission") } diff --git a/internal/mcp/edit_acknowledgement_wire_test.go b/internal/mcp/edit_acknowledgement_wire_test.go index b5ae8877b..5b4e7e102 100644 --- a/internal/mcp/edit_acknowledgement_wire_test.go +++ b/internal/mcp/edit_acknowledgement_wire_test.go @@ -110,7 +110,7 @@ func TestMutationStatusAcknowledgementSharesRPCAndPreservesPublication(t *testin h := s.boundToolHandler(func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { for _, name := range []string{"a.go", "b.go"} { path := filepath.Join(root, name) - record, err := s.commitFileMutation(ctx, "write_file", "", "", name, path, []byte("package p\n"), 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", name, path, []byte("package p\n"), 0o600) if err != nil { return nil, err } diff --git a/internal/mcp/edit_gate_seed_checkout_test.go b/internal/mcp/edit_gate_seed_checkout_test.go new file mode 100644 index 000000000..7632ae8cd --- /dev/null +++ b/internal/mcp/edit_gate_seed_checkout_test.go @@ -0,0 +1,63 @@ +//go:build gortex_edit_gate_seed + +package mcp + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func TestEditGateACKSeedReachesActualFacadeCheckoutWrite(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "ack-wait") + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + after := strings.Replace(string(before), "func helper() {}", "func helper() { println(1) }", 1) + require.NotEqual(t, string(before), after) + sum := sha256.Sum256([]byte(after)) + results := make(chan indexer.MutationResult, 1) + results <- indexer.MutationResult{Reindexed: true} + close(results) + mutation := &receiptCheckoutMutation{ticket: &indexer.CheckoutRefreshTicket{CheckoutID: "checkout-seed", Incarnation: "incarnation-seed", Root: dir, RepoPrefix: "fixture", ContentHash: hex.EncodeToString(sum[:]), Ticket: &indexer.MutationTicket{Path: path, Generation: 7, Done: results}}} + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + ctx = withCheckoutMutation(WithSessionID(ctx, "seed-facade"), mutation, dir) + // Negotiate and dispatch the actual facade shape used by the load harness. + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"integration-harness","version":"1.0"}}}`))) + frame, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": map[string]any{"name": "edit", "arguments": map[string]any{"operation": "file", "target": map[string]any{"file": "main.go"}, "match": "func helper() {}", "replacement": "func helper() { println(1) }", "output": map[string]any{"format": "json"}}}}) + require.NoError(t, err) + entered := make(chan struct{}) + editGateSeedACKEntered = func() { close(entered) } + t.Cleanup(func() { editGateSeedACKEntered = nil }) + done := make(chan any, 1) + go func() { done <- srv.MCPServer().HandleMessage(ctx, frame) }() + select { + case <-entered: + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("cancelled checkout seed did not join handler") + } + case reply := <-done: + raw, readErr := os.ReadFile(path) + require.NoError(t, readErr) + t.Fatalf("facade checkout returned before seed; committed=%t reply=%+v", strings.Contains(string(raw), "println(1)"), reply) + case <-ctx.Done(): + t.Fatal("checkout write neither admitted seed nor returned") + } + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, after, string(raw)) +} diff --git a/internal/mcp/edit_gate_seed_disabled.go b/internal/mcp/edit_gate_seed_disabled.go new file mode 100644 index 000000000..b54c046f3 --- /dev/null +++ b/internal/mcp/edit_gate_seed_disabled.go @@ -0,0 +1,10 @@ +//go:build !gortex_edit_gate_seed + +package mcp + +import "context" + +// Production builds cannot activate the deliberately slow regression seed. +func editGateACKSeed(context.Context, *Server) error { return nil } + +func editGateCheckoutACKSeed(context.Context, *Server) error { return nil } diff --git a/internal/mcp/edit_gate_seed_disabled_test.go b/internal/mcp/edit_gate_seed_disabled_test.go new file mode 100644 index 000000000..36a9b832a --- /dev/null +++ b/internal/mcp/edit_gate_seed_disabled_test.go @@ -0,0 +1,17 @@ +//go:build !gortex_edit_gate_seed + +package mcp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEditGateProductionBuildCannotEnableACKSeed(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "ack-wait") + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.NoError(t, editGateACKSeed(ctx, nil), "production build has no timer, hook or environment activation") +} diff --git a/internal/mcp/edit_gate_seed_enabled.go b/internal/mcp/edit_gate_seed_enabled.go new file mode 100644 index 000000000..b3b7a20fa --- /dev/null +++ b/internal/mcp/edit_gate_seed_enabled.go @@ -0,0 +1,49 @@ +//go:build gortex_edit_gate_seed + +package mcp + +import ( + "context" + "os" + "time" +) + +// Only the dedicated test build contains this switch. The caller owns the +// existing reindex-wait lap; this helper must never introduce another lap. +var editGateSeedACKEntered func() + +func editGateACKSeed(ctx context.Context, s *Server) error { + if os.Getenv("GORTEX_EDIT_GATE_SEED") != "ack-wait" { + return nil + } + resumeIntent := suspendSourceMutationWriteIntent(ctx) + defer resumeIntent() + // The synthetic legacy wait yields its existing dispatch permit, just + // like an ordinary publication wait. It owns no writer or extra lap. + waiting := make(chan struct{}) + defer close(waiting) + s.releaseDispatchSlotForWait(ctx, waiting) + if editGateSeedACKEntered != nil { + editGateSeedACKEntered() + } + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// The checkout adapter fast-ACKs an admitted ticket without a normal graph +// wait. Only a seed build records the deliberately restored legacy wait here. +// When admission/await are split, this belongs after the source path is released. +func editGateCheckoutACKSeed(ctx context.Context, s *Server) error { + if os.Getenv("GORTEX_EDIT_GATE_SEED") != "ack-wait" { + return nil + } + lap := s.beginAcknowledgementLap(ctx, editAckReindexWait) + defer lap.finish() + return editGateACKSeed(ctx, s) +} diff --git a/internal/mcp/edit_gate_seed_enabled_test.go b/internal/mcp/edit_gate_seed_enabled_test.go new file mode 100644 index 000000000..743d17434 --- /dev/null +++ b/internal/mcp/edit_gate_seed_enabled_test.go @@ -0,0 +1,55 @@ +//go:build gortex_edit_gate_seed + +package mcp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/indexer" +) + +func TestEditGateACKSeedRunsInsideActualAdmittedWrite(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "ack-wait") + srv, dir := setupTestServer(t) + published := make(chan indexer.MutationResult, 1) + published <- indexer.MutationResult{Reindexed: true} + close(published) + srv.SetWatcher(mutationTestWatcher{done: published}) + srv.mutationReindexWait = time.Nanosecond + entered := make(chan struct{}) + editGateSeedACKEntered = func() { close(entered) } + t.Cleanup(func() { editGateSeedACKEntered = nil }) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + req := mcplib.CallToolRequest{} + req.Params.Name = "edit_file" + req.Params.Arguments = map[string]any{"path": filepath.Join(dir, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(1) }", "format": "json"} + done := make(chan error, 1) + go func() { _, err := srv.runAcknowledgedEdit(ctx, req, srv.handleEditFile); done <- err }() + select { + case <-entered: + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("cancelled seed did not join the actual write handler") + } + case err := <-done: + raw, readErr := os.ReadFile(filepath.Join(dir, "main.go")) + require.NoError(t, readErr) + t.Fatalf("actual disk write returned before test seed admission: committed=%t error=%v", strings.Contains(string(raw), "println(1)"), err) + case <-ctx.Done(): + t.Fatal("neither actual write nor seed admitted") + } + raw, err := os.ReadFile(filepath.Join(dir, "main.go")) + require.NoError(t, err) + require.Contains(t, string(raw), "println(1)", "seed cancellation must not undo the preceding physical commit") +} diff --git a/internal/mcp/edit_serialization.go b/internal/mcp/edit_serialization.go index 66c827cee..885b981c1 100644 --- a/internal/mcp/edit_serialization.go +++ b/internal/mcp/edit_serialization.go @@ -1,9 +1,11 @@ package mcp import ( + "bytes" "context" "errors" "fmt" + "os" "path/filepath" "sort" "sync" @@ -12,6 +14,7 @@ import ( "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" "github.com/zzet/gortex/internal/viewmetrics" ) @@ -20,7 +23,7 @@ const ( mutationReceiptRetention = 10 * time.Minute ) -var mutationReceiptSequence atomic.Uint64 +var mutationReceiptSequence = epochMutationSequence() // mutationPathLocks serializes read-modify-write tool calls per on-disk path. // MCP requests can run concurrently, and atomic rename only makes each write @@ -41,12 +44,17 @@ type mutationPathLock struct { // mutation. A receipt identifies admitted publication work that can outlive the // request, either on a watcher or on the selected checkout's coordinator. type mutationReindexOutcome struct { - Reindexed bool - Pending bool - Receipt string - Generation uint64 - AppliedGeneration uint64 - Err error + Reindexed bool + Pending bool + Receipt string + // Same existing receipt ID retained internally when full ACK omits it. + publicationReceipt string + Generation uint64 + AppliedGeneration uint64 + Err error + // tiers is the immutable snapshot from the existing ticket owner. Never + // expose a mutable receipt (and its mutex/history) through an outcome. + tiers indexer.MutationTierState // DerivedFanout is the completeness fact for the bounded derived passes // the reindex ran. Additive and independent of Reindexed: the graph can // have read these bytes while the affected-by re-resolution over them was @@ -70,6 +78,7 @@ type mutationReceipt struct { mu sync.RWMutex result indexer.MutationResult completed bool + ticket *indexer.MutationTicket checkoutScoped bool checkoutID string checkoutIncarnation string @@ -128,15 +137,39 @@ type mutationSetScheduler interface { EnqueueFileMutations(context.Context, []string) (map[string]*indexer.MutationTicket, error) } -// acquireMutationPath waits for exclusive mutation access to path. Waiting is -// context-aware: a cancelled MCP request leaves the queue immediately, which -// lets its dispatcher goroutine finish and release admission capacity. -func acquireMutationPath(ctx context.Context, path string) (func(), error) { - if err := guardCheckoutMutationPath(ctx, path); err != nil { - return nil, err +type mutationPathAdmissionKey struct{} + +// A context proves ownership only while the captured lock set is held. The +// release bit is shared with every derived context, so saved contexts cannot +// authorize a later write after their owner returned the token. +type mutationPathAdmission struct { + keys map[string]struct{} + parent *mutationPathAdmission + released atomic.Bool +} + +func mutationPathAdmitted(ctx context.Context, key string) bool { + admission, _ := ctx.Value(mutationPathAdmissionKey{}).(*mutationPathAdmission) + for ; admission != nil; admission = admission.parent { + if !admission.released.Load() { + if _, ok := admission.keys[key]; ok { + return true + } + } } - path = filepath.Clean(path) + return false +} +// acquireMutationPath waits for exclusive mutation access and returns the +// context proving ownership of that captured path identity until release. +func acquireMutationPath(ctx context.Context, path string) (context.Context, func(), error) { + return acquireMutationPaths(ctx, []string{path}) +} + +// acquireMutationPathKey acquires an already-admitted, captured path identity. +// Keeping that key through acquisition also keeps release independent of any +// later symlink change. +func acquireMutationPathKey(ctx context.Context, path string) (func(), error) { mutationPathLocks.Lock() entry := mutationPathLocks.byPath[path] if entry == nil { @@ -171,34 +204,42 @@ func acquireMutationPath(ctx context.Context, path string) (func(), error) { // acquireMutationPaths locks a mutation set in lexical path order. A stable // order prevents deadlock between overlapping batches; cancellation while // waiting releases every previously acquired path in reverse order. -func acquireMutationPaths(ctx context.Context, paths []string) (func(), error) { +func acquireMutationPaths(ctx context.Context, paths []string) (context.Context, func(), error) { unique := make(map[string]struct{}, len(paths)) ordered := make([]string, 0, len(paths)) for _, path := range paths { - clean := filepath.Clean(path) - if _, exists := unique[clean]; exists { + // Admission applies to every spelling, even when two names resolve + // to the same lock. A denied alias must never disappear in dedupe. + if err := guardCheckoutMutationPath(ctx, path); err != nil { + return ctx, nil, err + } + key := pathkey.MutationKey(path) + if _, exists := unique[key]; exists || mutationPathAdmitted(ctx, key) { continue } - unique[clean] = struct{}{} - ordered = append(ordered, clean) + unique[key] = struct{}{} + ordered = append(ordered, key) } sort.Strings(ordered) releases := make([]func(), 0, len(ordered)) - for _, path := range ordered { - release, err := acquireMutationPath(ctx, path) + for _, key := range ordered { + release, err := acquireMutationPathKey(ctx, key) if err != nil { for i := len(releases) - 1; i >= 0; i-- { releases[i]() } - return nil, err + return ctx, nil, err } releases = append(releases, release) } - + parent, _ := ctx.Value(mutationPathAdmissionKey{}).(*mutationPathAdmission) + admission := &mutationPathAdmission{keys: unique, parent: parent} + ctx = context.WithValue(ctx, mutationPathAdmissionKey{}, admission) var once sync.Once - return func() { + return ctx, func() { once.Do(func() { + admission.released.Store(true) for i := len(releases) - 1; i >= 0; i-- { releases[i]() } @@ -220,7 +261,7 @@ func (s *Server) trackMutationTicket(ticket *indexer.MutationTicket) *mutationRe if s.multiIndexer != nil { repo = s.multiIndexer.RepoForFile(ticket.Path) } - return s.trackScopedMutationTicket(ticket, repo, "", "", nil) + return s.trackScopedMutationTicket(ticket, repo, "", "", nil, s.canonicalProjectionRetirement(ticket.Path)) } func (s *Server) trackCheckoutRefreshTicket(ticket *indexer.CheckoutRefreshTicket) *mutationReceipt { @@ -241,8 +282,9 @@ func (s *Server) trackCheckoutRecoveryTicket(ticket *indexer.CheckoutRefreshTick return s.trackScopedMutationTicket(ticket.Ticket, ticket.RepoPrefix, ticket.CheckoutID, ticket.Incarnation, captured) } -func (s *Server) trackScopedMutationTicket(ticket *indexer.MutationTicket, repo, checkoutID, incarnation string, recoveryCandidates map[string]struct{}) *mutationReceipt { +func (s *Server) trackScopedMutationTicket(ticket *indexer.MutationTicket, repo, checkoutID, incarnation string, recoveryCandidates map[string]struct{}, retirements ...*sourceProjectionRetirement) *mutationReceipt { receipt := &mutationReceipt{ + ticket: ticket, id: fmt.Sprintf("mutation-%d", mutationReceiptSequence.Add(1)), repo: repo, path: ticket.Path, @@ -297,6 +339,12 @@ func (s *Server) trackScopedMutationTicket(ticket *indexer.MutationTicket, repo, } s.scheduleMutationReceiptExpiry(receipt, retention) close(receipt.done) + // Read-only content verification is outside ACK completion and the + // repository lane. Canonical backend ownership needs no departed view pin. + // A successful notification merely triggers verification, never proves it. + if result.Err == nil && len(retirements) == 1 { + retirements[0].run() + } }() return receipt } @@ -441,6 +489,18 @@ func (s *Server) failedReceiptsBefore(paths []string, root string) map[string]st return eligible } +func (s *Server) captureWatcherTierRepair(paths []string, root string) func() { + path, ok := reindexCandidatePath(paths, root) + if !ok { + return nil + } + watcher, ok := s.currentWatcher().(interface{ CaptureTierRepair(string) func() }) + if !ok { + return nil + } + return watcher.CaptureTierRepair(path) +} + func (s *Server) resolveReindexedPathReceipts(reindexedPath string, eligible map[string]struct{}) { cleanPath := filepath.Clean(reindexedPath) s.mutationReceipts.Range(func(_, value any) bool { @@ -477,11 +537,15 @@ func (r *mutationReceipt) outcome(pending bool) mutationReindexOutcome { outcome := mutationReindexOutcome{ Pending: pending, Receipt: r.id, + publicationReceipt: r.id, Generation: r.generation, checkoutScoped: r.checkoutScoped, checkoutID: r.checkoutID, checkoutIncarnation: r.checkoutIncarnation, } + if r.ticket != nil { + outcome.tiers = r.ticket.TierState() + } if r.completed { outcome.Reindexed = r.result.Reindexed outcome.AppliedGeneration = r.result.AppliedGeneration @@ -532,75 +596,126 @@ func (s *Server) mutationWaitDuration() time.Duration { return defaultMutationReindexWait } -// mutationReindexState returns the graph-freshness state to expose after a -// successful disk mutation. A live watcher already owns debouncing, patch -// serialization, and latest-bytes reconciliation, so duplicating IndexFile in -// the request path both blocks the response and races the watcher. Embedded -// servers have no watcher and retain the synchronous freshness contract. -func (s *Server) mutationReindexState(ctx context.Context, absPath string) mutationReindexOutcome { +// sourceMutationAdmission separates committing/admitting under the path lock +// from passive graph waiting after the lock has been returned. +type sourceMutationAdmission struct { + policy sourceAckPolicy + receipt *mutationReceipt + immediate mutationReindexOutcome + inlinePath string + checkout *checkoutMutationState + move *moveObservation +} + +// admitSourceMutations captures publication ownership before another writer +// can commit this path. It never waits for a queued watcher publication. +func (s *Server) admitSourceMutations(ctx context.Context, absPath string) (admission sourceMutationAdmission) { + defer func() { admission.policy = s.sourceAckPolicy(ctx) }() if state := checkoutMutationFromContext(ctx); state != nil { - return s.refreshCheckoutMutation(ctx, absPath, state) - } - if watcher := s.currentWatcher(); watcher != nil { - // Admission is path-scoped and authoritative. Scheduling uses a detached - // context because the disk commit already happened; client cancellation - // must not leave the graph permanently stale. - if scheduler, ok := watcher.(mutationScheduler); ok { - // The reindex is detached from this request, so the request's own - // lease cannot be what keeps its payload alive: join it for the - // admitted work and let the receipt hold the pin until the ticket - // reports. Nil whenever no generation stack answered this request. - pin := handoffRequestView(ctx, viewmetrics.HandoffFileMutation) - publication := s.committedPublicationRecord(ctx, absPath) - admissionLap := s.beginAcknowledgementLap(ctx, editAckTicketAdmission) - ticket, scheduleErr := scheduler.EnqueueToolMutation(indexer.WithPublicationRecord(context.WithoutCancel(ctx), publication), absPath) - admissionLap.finish() - if scheduleErr != nil { - failMutationPhases(publication) - pin.release() - return mutationReindexOutcome{Err: scheduleErr} - } - if ticket == nil { - failMutationPhases(publication) - // Admission declined to schedule anything, so nothing outlives - // this frame and the fall-through below reindexes inline. - pin.release() - } - if ticket != nil { - receipt := s.trackMutationTicket(ticket) - s.bindReadRepairAdmission(absPath, ticket.PublicationRecord, receipt) - receipt.pinView(pin) - // The disk write and ticket admission are complete. This wait - // must not announce another ordinary writer while the watcher - // publishes; actual queued writes still preempt chain folds. - resumeIntent := suspendSourceMutationWriteIntent(ctx) - defer resumeIntent() - // Only waiting from here on: let other requests use this - // dispatch slot. A publication that lands first needs none. - waitCtx, cancel := context.WithTimeout(ctx, s.mutationWaitDuration()) - defer cancel() - waitLap := s.beginAcknowledgementLap(ctx, editAckReindexWait) - if !s.releaseDispatchSlotForWait(waitCtx, receipt.done) { - waitLap.finish() - return receipt.outcome(true) - } - select { - case <-receipt.done: - waitLap.finish() - outcome := receipt.outcome(false) - outcome.Receipt = "" - return outcome - case <-waitCtx.Done(): - waitLap.finish() - return receipt.outcome(true) - } + if _, queued := state.mutation.(checkoutMutationScheduler); queued { + return sourceMutationAdmission{immediate: s.refreshCheckoutMutation(ctx, absPath, state)} + } + return sourceMutationAdmission{inlinePath: absPath, checkout: state} + } + if scheduler, ok := s.currentWatcher().(mutationScheduler); ok { + pin := handoffRequestView(ctx, viewmetrics.HandoffFileMutation) + publication := s.committedPublicationRecord(ctx, absPath) + admissionLap := s.beginAcknowledgementLap(ctx, editAckTicketAdmission) + ticket, err := scheduler.EnqueueToolMutation(indexer.WithPublicationRecord(context.WithoutCancel(ctx), publication), absPath) + admissionLap.finish() + if err != nil { + failMutationPhases(publication) + pin.release() + return sourceMutationAdmission{immediate: mutationReindexOutcome{Err: err}} + } + if ticket != nil { + receipt := s.trackMutationTicket(ticket) + s.bindReadRepairAdmission(absPath, ticket.PublicationRecord, receipt) + receipt.pinView(pin) + return sourceMutationAdmission{receipt: receipt} + } + failMutationPhases(publication) + pin.release() + } + return sourceMutationAdmission{inlinePath: absPath} +} + +// awaitSourceMutations performs only the admitted publication wait for a live +// watcher. Embedded adapters retain their synchronous refresh after unlocking. +func (s *Server) awaitSourceMutations(ctx context.Context, admission sourceMutationAdmission) mutationReindexOutcome { + if admission.receipt == nil { + if admission.checkout != nil { + return s.refreshCheckoutMutation(ctx, admission.inlinePath, admission.checkout) + } + if admission.inlinePath == "" { + outcome := admission.immediate + if outcome.Err == nil && outcome.Receipt != "" { + outcome.Err = editGateCheckoutACKSeed(ctx, s) } + return outcome + } + if err := ctx.Err(); err != nil { + return mutationReindexOutcome{Err: err} } + retirement := s.canonicalProjectionRetirement(admission.inlinePath) + var reindexed bool + if admission.move != nil { + reindexed = admission.move.reindex(ctx, admission.inlinePath) + } else { + reindexed = s.reindexFile(admission.inlinePath) + } + if reindexed { + retirement.run() + } + return mutationReindexOutcome{Reindexed: reindexed} } - if err := ctx.Err(); err != nil { - return mutationReindexOutcome{Err: err} + receipt := admission.receipt + resumeIntent := suspendSourceMutationWriteIntent(ctx) + defer resumeIntent() + if admission.policy.freshness.requireFresh { + err := s.awaitSourceAckTier(ctx, admission.policy, []string{receipt.path}, receipt.done) + outcome := receipt.outcome(true) + if err != nil { + outcome.Err = err + } + return outcome + } + if admission.policy.local() { + outcome := receipt.outcome(true) + if err := editGateCheckoutACKSeed(ctx, s); err != nil { + outcome.Err = err + } + return outcome + } + waitLap := s.beginAcknowledgementLap(ctx, editAckReindexWait) + if err := editGateACKSeed(ctx, s); err != nil { + waitLap.finish() + outcome := receipt.outcome(true) + outcome.Err = err + return outcome + } + waitCtx, cancel := context.WithTimeout(ctx, s.mutationWaitDuration()) + defer cancel() + if !s.releaseDispatchSlotForWait(waitCtx, receipt.done) { + waitLap.finish() + return receipt.outcome(true) + } + select { + case <-receipt.done: + waitLap.finish() + outcome := receipt.outcome(false) + outcome.Receipt = "" + return outcome + case <-waitCtx.Done(): + waitLap.finish() + return receipt.outcome(true) } - return mutationReindexOutcome{Reindexed: s.reindexFile(absPath)} +} + +// Keep the combined entry for callers that have no source path lock. Source +// handlers admit first and explicitly release their locks before awaiting. +func (s *Server) mutationReindexState(ctx context.Context, absPath string) mutationReindexOutcome { + return s.awaitSourceMutations(ctx, s.admitSourceMutations(ctx, absPath)) } // mutationCheckoutScope is metadata-only so receipts remain inspectable while @@ -792,12 +907,22 @@ func (s *Server) mutationReceiptState(id string) (mutationReindexOutcome, bool) // health is only authoritative after completed reindex; reading it while a // watcher patch is pending would surface stale parse errors and provoke an // unnecessary source re-read. -func (s *Server) attachMutationFreshness(resp map[string]any, relPath, absPath string, outcome mutationReindexOutcome) { +func attachMutationFreshnessFields(resp map[string]any, outcome mutationReindexOutcome) { + for _, key := range []string{"reindex_pending", "reindex_error", "reindex_receipt", "reindex_generation", "applied_generation", "syntax_health", "derived_fanout"} { + delete(resp, key) + } resp["reindexed"] = outcome.Reindexed // graph_status is the freshness half of the mutation contract, named so it // reads next to disk_status (mutation_commit.go) rather than having to be // inferred from the reindexed / reindex_pending / reindex_error triple. resp["graph_status"] = graphStatusFor(outcome) + if outcome.Err != nil { + resp["reindex_error"] = outcome.Err.Error() + } + if outcome.Pending { + resp["reindex_pending"] = true + } + attachMutationTierState(resp, outcome.tiers) attachDerivedFanout(resp, outcome.DerivedFanout) if outcome.Generation > 0 { resp["reindex_generation"] = outcome.Generation @@ -808,6 +933,10 @@ func (s *Server) attachMutationFreshness(resp map[string]any, relPath, absPath s if outcome.Receipt != "" { resp["reindex_receipt"] = outcome.Receipt } +} + +func (s *Server) attachMutationFreshness(resp map[string]any, relPath, absPath string, outcome mutationReindexOutcome) { + attachMutationFreshnessFields(resp, outcome) if outcome.Pending { resp["reindex_pending"] = true return @@ -818,3 +947,37 @@ func (s *Server) attachMutationFreshness(resp map[string]any, relPath, absPath s } } } + +// plannedSource retains the bytes used to calculate a multi-file rewrite. +// Admission verifies the complete set before the first physical effect. +type plannedSource struct { + path string + before []byte + existed bool +} + +func lockPlannedSources(ctx context.Context, plans []plannedSource) (context.Context, func(), error) { + paths := make([]string, 0, len(plans)) + for _, plan := range plans { + paths = append(paths, plan.path) + } + ctx, release, err := acquireMutationPaths(ctx, paths) + if err != nil { + return ctx, nil, err + } + for _, plan := range plans { + current, readErr := os.ReadFile(plan.path) + if !plan.existed && errors.Is(readErr, os.ErrNotExist) { + continue + } + if readErr != nil || !plan.existed || !bytes.Equal(current, plan.before) { + release() + return ctx, nil, fmt.Errorf("%w: planned source changed before admission: %s", errMutationNotApplied, plan.path) + } + } + if err := ctx.Err(); err != nil { + release() + return ctx, nil, err + } + return ctx, release, nil +} diff --git a/internal/mcp/edit_serialization_test.go b/internal/mcp/edit_serialization_test.go index 4ebf78a59..4f5deef09 100644 --- a/internal/mcp/edit_serialization_test.go +++ b/internal/mcp/edit_serialization_test.go @@ -3,14 +3,18 @@ package mcp import ( "context" "errors" + "fmt" "os" "path/filepath" "sync/atomic" "testing" "time" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" "github.com/zzet/gortex/internal/query" ) @@ -66,7 +70,7 @@ type degradedMutationTestWatcher struct { func (w degradedMutationTestWatcher) DegradedReason() string { return w.reason } func mutationRefs(path string) int { - path = filepath.Clean(path) + path = pathkey.MutationKey(path) mutationPathLocks.Lock() defer mutationPathLocks.Unlock() if entry := mutationPathLocks.byPath[path]; entry != nil { @@ -89,7 +93,7 @@ func waitForMutationRefs(t *testing.T, path string, want int) { func TestAcquireMutationPathSerializesAndCleansUp(t *testing.T) { path := filepath.Join(t.TempDir(), "same.go") - firstRelease, err := acquireMutationPath(context.Background(), path) + _, firstRelease, err := acquireMutationPath(context.Background(), path) if err != nil { t.Fatalf("first acquire: %v", err) } @@ -97,7 +101,7 @@ func TestAcquireMutationPathSerializesAndCleansUp(t *testing.T) { acquired := make(chan func(), 1) errs := make(chan error, 1) go func() { - release, acquireErr := acquireMutationPath(context.Background(), path) + _, release, acquireErr := acquireMutationPath(context.Background(), path) if acquireErr != nil { errs <- acquireErr return @@ -126,7 +130,7 @@ func TestAcquireMutationPathSerializesAndCleansUp(t *testing.T) { func TestAcquireMutationPathCancellationReleasesQueueReference(t *testing.T) { path := filepath.Join(t.TempDir(), "cancel.go") - ownerRelease, err := acquireMutationPath(context.Background(), path) + _, ownerRelease, err := acquireMutationPath(context.Background(), path) if err != nil { t.Fatalf("owner acquire: %v", err) } @@ -134,7 +138,7 @@ func TestAcquireMutationPathCancellationReleasesQueueReference(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) errs := make(chan error, 1) go func() { - _, acquireErr := acquireMutationPath(ctx, path) + _, _, acquireErr := acquireMutationPath(ctx, path) errs <- acquireErr }() waitForMutationRefs(t, path, 2) @@ -159,7 +163,7 @@ func TestAcquireMutationPathsCancellationReleasesEarlierLocks(t *testing.T) { first, second = second, first } - releaseSecond, err := acquireMutationPath(context.Background(), second) + _, releaseSecond, err := acquireMutationPath(context.Background(), second) if err != nil { t.Fatal(err) } @@ -168,7 +172,7 @@ func TestAcquireMutationPathsCancellationReleasesEarlierLocks(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) result := make(chan error, 1) go func() { - release, lockErr := acquireMutationPaths(ctx, []string{second, first, first}) + _, release, lockErr := acquireMutationPaths(ctx, []string{second, first, first}) if release != nil { release() } @@ -288,23 +292,28 @@ func TestAttachMutationFreshnessPendingOmitsStaleSyntaxHealth(t *testing.T) { } } -func TestApplyBatchFileEditSerializesConcurrentWrites(t *testing.T) { +func TestAtomicBatchSerializesConcurrentFileEdits(t *testing.T) { path := filepath.Join(t.TempDir(), "concurrent.txt") if err := os.WriteFile(path, []byte("alpha beta\n"), 0o644); err != nil { t.Fatal(err) } - s := &Server{watcher: mutationTestWatcher{}, session: newSessionState()} + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) start := make(chan struct{}) results := make(chan batchEditResult, 2) - for _, edit := range []batchEditItem{ - {Path: path, OldString: "alpha", NewString: "ALPHA"}, - {Path: path, OldString: "beta", NewString: "BETA"}, + for i, edit := range []batchEditItem{ + {Op: "edit_file", Path: path, OldString: "alpha", NewString: "ALPHA"}, + {Op: "edit_file", Path: path, OldString: "beta", NewString: "BETA"}, } { edit := edit go func() { <-start - results <- s.applyBatchFileEdit(context.Background(), edit, true) + receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{edit}, fmt.Sprintf("concurrent-file-%d", i)) + if err != nil { + results <- batchEditResult{Status: "failed", Error: err.Error()} + return + } + results <- receipt.Results[0] }() } close(start) @@ -323,7 +332,7 @@ func TestApplyBatchFileEditSerializesConcurrentWrites(t *testing.T) { } } -func TestApplyBatchSymbolEditFallsBackAfterPendingLineShift(t *testing.T) { +func TestAtomicBatchSymbolEditFallsBackAfterPendingLineShift(t *testing.T) { path := filepath.Join(t.TempDir(), "shifted.go") content := "package sample\n\nfunc target() {\n\tprintln(\"old\")\n}\n\nfunc other() {}\n" if err := os.WriteFile(path, []byte(content), 0o644); err != nil { @@ -340,17 +349,18 @@ func TestApplyBatchSymbolEditFallsBackAfterPendingLineShift(t *testing.T) { EndLine: 7, } g.AddNode(node) - s := &Server{ - engine: query.NewEngine(g), - graph: g, - watcher: mutationTestWatcher{}, - session: newSessionState(), - } - result := s.applyBatchSymbolEdit(context.Background(), batchEditItem{ + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + s.engine, s.graph = query.NewEngine(g), g + receipt, runErr := s.runBatchTransaction(context.Background(), []batchEditItem{{ + Op: "edit_symbol", SymbolID: node.ID, OldSource: "println(\"old\")", NewSource: "println(\"new\")", - }, true) + }}, "shifted-symbol") + if runErr != nil { + t.Fatal(runErr) + } + result := receipt.Results[0] if result.Status != "applied" || !result.Reindexed || result.ReindexPending || result.ReindexError != "" { t.Fatalf("unexpected result: %+v", result) } @@ -363,3 +373,21 @@ func TestApplyBatchSymbolEditFallsBackAfterPendingLineShift(t *testing.T) { t.Fatalf("stale-range fallback result:\n%s\nwant:\n%s", got, want) } } + +func TestMutationFreshnessFieldsKeepActualFailureAndClearPreviousState(t *testing.T) { + resp := map[string]any{"reindex_error": "old failure", "reindex_pending": true, "reindex_receipt": "old", "syntax_health": "old"} + attachMutationFreshnessFields(resp, mutationReindexOutcome{Err: errors.New("current publication failure")}) + require.Equal(t, "current publication failure", resp["reindex_error"]) + require.Equal(t, mutationGraphFailed, resp["graph_status"]) + require.NotContains(t, resp, "reindex_pending") + require.NotContains(t, resp, "reindex_receipt") + require.NotContains(t, resp, "syntax_health") + attachMutationFreshnessFields(resp, mutationReindexOutcome{Pending: true, Receipt: "current", Generation: 2}) + require.NotContains(t, resp, "reindex_error") + require.Equal(t, true, resp["reindex_pending"]) + require.Equal(t, "current", resp["reindex_receipt"]) + attachMutationFreshnessFields(resp, mutationReindexOutcome{Reindexed: true, Generation: 2, AppliedGeneration: 2}) + require.NotContains(t, resp, "reindex_error") + require.NotContains(t, resp, "reindex_pending") + require.Equal(t, mutationGraphFresh, resp["graph_status"]) +} diff --git a/internal/mcp/edit_symbol_measurement_contract_test.go b/internal/mcp/edit_symbol_measurement_contract_test.go index 0eac772f8..5f8b62a12 100644 --- a/internal/mcp/edit_symbol_measurement_contract_test.go +++ b/internal/mcp/edit_symbol_measurement_contract_test.go @@ -98,11 +98,19 @@ func TestEditSymbolMeasurementFixtureUsesActualSpliceAndExplicitAbsence(t *testi status := s.mutationStatusPayload(record) require.Equal(t, "edit_symbol", status["tool"]) ack := status["acknowledgement"].(acknowledgementPayload) - require.Equal(t, "not_applicable_text_splice", ack.ParserScope) - require.Equal(t, "not_applicable", ack.PhaseStates["parse"]) + // Text splicing itself has no parser. The same source operation + // now builds its committed file projection, whose actual parse + // belongs to this ACK rather than a fabricated absent phase. + require.Equal(t, "mixed_observed_parser_paths", ack.ParserScope) + require.Equal(t, "measured", ack.PhaseStates["parse"]) parse, present := ack.ObservedExecutionMS["parse"] require.True(t, present) - require.Nil(t, parse) + require.NotNil(t, parse) + require.GreaterOrEqual(t, *parse, 0.0, "actual completed work may have equal monotonic endpoints") + require.Positive(t, ack.Counts["parse"].Started) + require.Equal(t, ack.Counts["parse"].Started, ack.Counts["parse"].Completed) + require.Zero(t, ack.Counts["parse"].NotApplicable) + require.True(t, ack.Disjoint) actual, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, strings.Replace(source, declaration, replacement, 1), string(actual)) diff --git a/internal/mcp/facade_tools.go b/internal/mcp/facade_tools.go index 474cefcf4..706d10ebb 100644 --- a/internal/mcp/facade_tools.go +++ b/internal/mcp/facade_tools.go @@ -1169,6 +1169,11 @@ func (s *Server) invokeFacadeSpec(ctx context.Context, req mcpgo.CallToolRequest forwarded.Params.Name = spec.Legacy forwarded.Params.Arguments = normalized forwarded.Params.RawArguments = nil + if refusal := s.awaitWatchedFreshness(ctx, spec.Legacy, forwarded, takeRequestFreshness(&forwarded)); refusal != nil { + return refusal, nil + } + ctx = s.captureToolFreshness(ctx) + ctx, forwarded = s.reserveToolFreshnessBudget(ctx, spec.Legacy, forwarded) result, err = legacy.handler(ctx, forwarded) if err == nil { // Book the retrieval half of the savings ledger under the LEGACY tool @@ -1177,12 +1182,12 @@ func (s *Server) invokeFacadeSpec(ctx context.Context, req mcpgo.CallToolRequest // handler actually retrieved, not the riders bolted on afterwards. raw := result afterFreshSymbolAcceptance(ctx, func() { s.recordRetrievalSavings(ctx, spec.Legacy, raw) }) - result = s.decorateFacadeFreshness(spec.Legacy, forwarded, result) + result = s.decorateFacadeFreshness(ctx, spec.Legacy, forwarded, result) } result = decorateFacadeResultIdentity(result, spec) if state, _ := ctx.Value(freshSymbolAttemptKey{}).(*freshSymbolAttempt); state != nil { state.decorate = func(raw *mcpgo.CallToolResult) *mcpgo.CallToolResult { - return decorateFacadeResultIdentity(s.decorateFacadeFreshness(spec.Legacy, forwarded, raw), spec) + return decorateFacadeResultIdentity(s.decorateFacadeFreshness(ctx, spec.Legacy, forwarded, raw), spec) } } return result, err @@ -1483,14 +1488,8 @@ func blockedAnalyzeKindResult(kind string) *mcpgo.CallToolResult { // The outer facade middleware only sees compact names/targets (read, // relations, target.file, ...), so applying the policy there would miss the // legacy path/id fields the rider is deliberately keyed to. -func (s *Server) decorateFacadeFreshness(legacy string, req mcpgo.CallToolRequest, result *mcpgo.CallToolResult) *mcpgo.CallToolResult { - if rider := s.freshnessRiderFor(legacy, req); rider != nil { - return decorateResultWithFreshness(result, rider) - } - if isFreshnessListTool(legacy) { - return s.decorateListResultWithFreshness(result) - } - return result +func (s *Server) decorateFacadeFreshness(ctx context.Context, legacy string, req mcpgo.CallToolRequest, result *mcpgo.CallToolResult) *mcpgo.CallToolResult { + return s.decorateToolFreshness(ctx, legacy, req, result) } func facadeLegacyManagesOwnOverlay(name string) bool { diff --git a/internal/mcp/freshness_explicit_contract_test.go b/internal/mcp/freshness_explicit_contract_test.go new file mode 100644 index 000000000..5ec13dcd6 --- /dev/null +++ b/internal/mcp/freshness_explicit_contract_test.go @@ -0,0 +1,228 @@ +package mcp + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" +) + +type explicitTierWatcher struct { + watchedTierFixture + enabled bool +} + +func (w *explicitTierWatcher) TierAuthority(prefix string) bool { return w.enabled && prefix == "repo" } + +func explicitFreshnessFixture(t *testing.T) (*Server, context.Context, func()) { + t.Helper() + stack := newViewStackWithRepos(t, false) + require.NoError(t, stack.leases.RegisterRepositoryOwner(viewTestOwner())) + authority := indexer.NewOutputGenerationAuthority(stack.leases) + mutate := func() { + t.Helper() + receipt, err := authority.Begin(t.Context(), indexer.OutputEntryWatcherDirScan, indexer.OutputMutationTarget{ + Kind: indexer.OutputGenerationLegacy, OwnerKey: "root:" + stack.repoRoot, RepoPrefix: "repo", RootPath: stack.repoRoot, + }) + require.NoError(t, err) + stack.store.AddNode(viewRepoNode("repo/target.go::Target", "Target", graph.KindFunction, "repo/target.go", 1)) + require.NoError(t, receipt.Complete()) + } + mutate() + stack.srv.multiIndexer.CompleteStartupRelations() + stack.srv.SetWatcher(&explicitTierWatcher{enabled: true}) + ctx := withRequestView(t.Context(), &requestView{reader: newBaseGraphReader(stack.store, "repo"), baseNarrowed: true}) + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{"require_fresh": true} + ctx, release := stack.srv.captureExplicitFreshness(ctx, req) + require.NotNil(t, release) + t.Cleanup(release) + return stack.srv, ctx, mutate +} + +func TestRequireFreshReportsExplicitGraphTier(t *testing.T) { + for _, scope := range []string{"file", "relations", "derived"} { + for _, format := range []string{"json", "toon", "text", "gcx", "compact"} { + t.Run(scope+"/"+format, func(t *testing.T) { + s, ctx, _ := explicitFreshnessFixture(t) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": "repo/target.go::Target", "require_fresh": true, "freshness_scope": scope, "format": format} + body := "callers[0]{id}:" + switch format { + case "json": + body = `{"callers":[],"complete":false}` + case "gcx": + body = "GCX1 tool=get_callers\nrow1" + case "compact": + req.Params.Arguments.(map[string]any)["compact"] = true + } + original := mcp.NewToolResultText(body) + got := s.decorateToolFreshness(ctx, req.Params.Name, req, original) + if format == "compact" { + require.Same(t, original, got, "compact has no new explicit-fresh contract") + return + } + switch format { + case "json": + payload := resultJSON(t, got) + fields, ok := payload["freshness"].(map[string]any) + require.True(t, ok, resultText(got)) + require.Equal(t, "fresh", fields["graph_freshness"]) + require.Equal(t, scope, fields["freshness_scope"]) + require.EqualValues(t, 0, fields["pending_file_count"]) + require.Equal(t, false, payload["complete"], "freshness must preserve an answer's completeness") + case "gcx": + require.Contains(t, resultText(got), "graph_freshness=fresh") + require.Contains(t, resultText(got), "# "+scope+": fresh (0 files)\n") + default: + require.True(t, strings.HasPrefix(resultText(got), scope+": fresh (0 files)\n"), resultText(got)) + } + }) + } + } +} + +func TestRequireFreshRejectsCompletedMutationDuringRead(t *testing.T) { + s, ctx, mutate := explicitFreshnessFixture(t) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": "repo/target.go::Target", "require_fresh": true, "format": "json"} + before := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + require.Equal(t, "fresh", resultJSON(t, before)["freshness"].(map[string]any)["graph_freshness"]) + mutate() // The accepted publication completes; no pending path survives. + after := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + require.Equal(t, "unknown", resultJSON(t, after)["freshness"].(map[string]any)["graph_freshness"]) +} + +func TestRequireFreshUsesActualAutoCanonicalReader(t *testing.T) { + s, _, _ := explicitFreshnessFixture(t) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": "repo/target.go::Target", "require_fresh": true, "format": "json"} + got, err := s.wrapToolHandler(s.handleGetCallers)(t.Context(), req) + require.NoError(t, err) + require.False(t, got.IsError, resultText(got)) + require.Equal(t, "fresh", resultJSON(t, got)["freshness"].(map[string]any)["graph_freshness"]) + require.NotContains(t, req.GetArguments(), "view", "AUTO must not be replaced by a BASE control") +} + +func TestRequireFreshRejectsUntrackedCanonicalWatcher(t *testing.T) { + s, ctx, _ := explicitFreshnessFixture(t) + s.SetWatcher(&explicitTierWatcher{enabled: false}) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": "repo/target.go::Target", "require_fresh": true, "format": "json"} + got := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + require.Equal(t, "unknown", resultJSON(t, got)["freshness"].(map[string]any)["graph_freshness"]) +} + +func TestRequireFreshAutoOwnerAddedDuringReadIsUnknown(t *testing.T) { + s, _, _ := explicitFreshnessFixture(t) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": "repo/target.go::Target", "require_fresh": true, "format": "json"} + ctx, release := s.captureExplicitFreshness(t.Context(), req) + require.NotNil(t, release) + defer release() + before := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + require.Equal(t, "fresh", resultJSON(t, before)["freshness"].(map[string]any)["graph_freshness"]) + _, err := s.multiIndexer.TrackRepoCtx(t.Context(), config.RepoEntry{Name: "added", Path: setupMiniRepo(t, "added")}) + require.NoError(t, err) + after := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + require.Equal(t, "unknown", resultJSON(t, after)["freshness"].(map[string]any)["graph_freshness"]) +} + +func TestRequireFreshRelationPayloadDoesNotUseFileTierAlone(t *testing.T) { + for _, tool := range []string{"get_file_summary", "get_editing_context"} { + t.Run(tool, func(t *testing.T) { + s, ctx, _ := explicitFreshnessFixture(t) + watcher := s.currentWatcher().(*explicitTierWatcher) + watcher.paths = map[string]indexer.MutationTierState{ + s.multiIndexer.ResolveFilePath("repo/target.go"): {Generation: 1, AckAt: time.Now(), FileGraphAt: time.Now()}, + } + req := mcp.CallToolRequest{} + req.Params.Name = tool + req.Params.Arguments = map[string]any{"path": "repo/target.go", "require_fresh": true, "format": "json"} + got := s.decorateToolFreshness(ctx, tool, req, mcp.NewToolResultText(`{"callers":["old"]}`)) + fields := resultJSON(t, got)["freshness"].(map[string]any) + require.Equal(t, "pending", fields["graph_freshness"]) + require.Equal(t, "relations", fields["freshness_scope"]) + }) + } +} + +func TestRequireFreshFacadeForwardsExplicitTierRequest(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + s, ctx, _ := explicitFreshnessFixture(t) + req := mcp.CallToolRequest{} + req.Params.Name = "relations" + req.Params.Arguments = map[string]any{ + "operation": "callers", "target": map[string]any{"symbol": "repo/target.go::Target"}, + "options": map[string]any{"require_fresh": true}, "output": map[string]any{"format": format}, + } + got, err := s.handleFacade(ctx, "relations", req) + require.NoError(t, err) + require.False(t, got.IsError, resultText(got)) + if format == "json" { + require.Equal(t, "fresh", resultJSON(t, got)["freshness"].(map[string]any)["graph_freshness"]) + } else { + require.True(t, strings.HasPrefix(resultText(got), "relations: fresh (0 files)\n"), resultText(got)) + } + }) + } +} + +func TestRequireFreshDoesNotInventGraphAuthority(t *testing.T) { + for _, route := range []string{"unwatched", "checkout", "source", "error"} { + t.Run(route, func(t *testing.T) { + s, _ := newRelationFixture(t) + ctx := t.Context() + if route != "unwatched" { + s.SetWatcher(&watchedTierFixture{}) + } + if route == "checkout" || route == "source" { + view := &requestView{reader: s.graph} + if route == "source" { + view.sourceScope = "file" + } + ctx = withRequestView(ctx, view) + } + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": targetID(t, s), "require_fresh": true, "format": "json"} + original := mcp.NewToolResultText(`{"callers":[]}`) + if route == "error" { + original.IsError = true + } + require.Same(t, original, s.decorateToolFreshness(ctx, req.Params.Name, req, original)) + }) + } +} + +func TestRequireFreshKeepsPreReadPublicationGap(t *testing.T) { + s, root := newRelationFixture(t) + watcher := &watchedTierFixture{paths: map[string]indexer.MutationTierState{ + filepath.Join(root, "target.go"): {Generation: 1, AckAt: time.Now()}, + }} + s.SetWatcher(watcher) + ctx := s.captureToolFreshness(t.Context()) + watcher.mu.Lock() + watcher.paths = nil + watcher.mu.Unlock() + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": targetID(t, s), "require_fresh": true, "format": "json"} + got := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + fields := resultJSON(t, got)["freshness"].(map[string]any) + require.Equal(t, "pending", fields["graph_freshness"], "a completed gap cannot certify rows read before publication") +} diff --git a/internal/mcp/freshness_idle_budget_contract_test.go b/internal/mcp/freshness_idle_budget_contract_test.go new file mode 100644 index 000000000..e9f065e63 --- /dev/null +++ b/internal/mcp/freshness_idle_budget_contract_test.go @@ -0,0 +1,116 @@ +package mcp + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" +) + +type freshnessIdleReadCounter struct { + graph.Reader + nodes, inEdges int +} + +func (r *freshnessIdleReadCounter) GetNode(id string) *graph.Node { + r.nodes++ + return r.Reader.GetNode(id) +} + +func (r *freshnessIdleReadCounter) GetInEdges(id string) []*graph.Edge { + r.inEdges++ + return r.Reader.GetInEdges(id) +} + +func TestFreshnessResponseContractIdleWatcherSkipsRelationReads(t *testing.T) { + s, _ := newRelationFixture(t) + id := targetID(t, s) + s.SetWatcher(&watchedTierFixture{}) + counter := &freshnessIdleReadCounter{Reader: s.graph} + // A narrowed base reader remains canonical: it must neither bypass the + // watcher nor build the seed's relation scope when no path is pending. + ctx := withRequestView(t.Context(), &requestView{reader: counter, baseNarrowed: true}) + require.Empty(t, s.freshnessFor(ctx, "relations", []string{id}, nil).state) + require.Equal(t, [2]int{0, 0}, [2]int{counter.nodes, counter.inEdges}, + "an idle watcher must add no seed lookup or in-edge scan to a read") +} + +func TestFreshnessResponseContractFinalPayloadHonorsByteBudget(t *testing.T) { + for _, name := range []string{"callers_compact", "file_summary_json", "facade_callers_compact"} { + t.Run(name, func(t *testing.T) { + s, root := newRelationFixture(t) + ctx := t.Context() + if name == "facade_callers_compact" { + const session = "freshness-budget-facade" + s.NoteSessionToolPolicy(session, FacadeSurfaceVersion, "hide") + ctx = WithSessionID(ctx, session) + } + seed := targetID(t, s) + // Many real graph rows let the existing structured and text budget + // policies remove rows to make room for the freshness label. + for i := 0; i < 40; i++ { + id := fmt.Sprintf("target.go::BudgetCaller%02d", i) + s.graph.AddNode(&graph.Node{ID: id, Name: fmt.Sprintf("BudgetCaller%02d", i), + Kind: graph.KindFunction, FilePath: "target.go", StartLine: i + 10, EndLine: i + 10, Language: "go"}) + s.graph.AddEdge(&graph.Edge{From: id, To: seed, Kind: graph.EdgeCalls, + FilePath: "target.go", Line: i + 10, Origin: graph.OriginASTResolved, Confidence: 1}) + } + call := func(maxBytes int) *mcp.CallToolResult { + t.Helper() + req := mcp.CallToolRequest{} + var handler func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) + switch name { + case "callers_compact": + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": seed, "compact": true, "max_bytes": maxBytes} + handler = s.handleGetCallers + case "file_summary_json": + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "target.go", "format": "json", "max_bytes": maxBytes} + handler = s.handleGetFileSummary + case "facade_callers_compact": + req.Params.Name = "relations" + req.Params.Arguments = map[string]any{"operation": "callers", "target": map[string]any{"symbol": seed}, + "output": map[string]any{"compact": true, "max_bytes": maxBytes}} + handler = func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return s.handleFacade(ctx, "relations", req) + } + } + res, err := s.wrapToolHandler(handler)(ctx, req) + require.NoError(t, err) + require.False(t, res.IsError, resultText(res)) + return res + } + s.SetWatcher(&watchedTierFixture{}) + // Use the complete fresh answer's byte length as an explicit supported + // cap. The same rows fit before decoration; a pending answer must now + // keep its label and fit that cap by returning fewer rows if needed. + cap := len(resultText(call(0))) + require.Greater(t, cap, 600, "fixture must leave room for a useful labelled answer") + require.LessOrEqual(t, len(resultText(call(cap))), cap, "fresh control must honor max_bytes") + s.SetWatcher(&watchedTierFixture{paths: map[string]indexer.MutationTierState{ + filepath.Join(root, "target.go"): {Generation: 1, AckAt: time.Now()}, + }}) + res := call(cap) + text := resultText(res) + if name == "file_summary_json" { + fields, ok := resultJSON(t, res)["freshness"].(map[string]any) + require.True(t, ok, "budget must preserve structured freshness") + require.Equal(t, "pending", fields["graph_freshness"]) + require.Equal(t, "file", fields["freshness_scope"]) + } else { + require.True(t, strings.HasPrefix(text, "relations: pending (1 files)\n"), + "budget must preserve the relations label: %s", text) + } + require.LessOrEqual(t, len(text), cap, "max_bytes bounds the final decorated response") + }) + } +} diff --git a/internal/mcp/freshness_response_contract_test.go b/internal/mcp/freshness_response_contract_test.go new file mode 100644 index 000000000..a954c2d1e --- /dev/null +++ b/internal/mcp/freshness_response_contract_test.go @@ -0,0 +1,68 @@ +package mcp + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/indexer" +) + +func TestFreshnessResponseContractNormalizesFileReadPaths(t *testing.T) { + for _, tool := range []string{"read_file", "get_file_summary", "get_editing_context"} { + t.Run(tool, func(t *testing.T) { + s, root := newRelationFixture(t) + path := filepath.Join(root, "target.go") + s.SetWatcher(&watchedTierFixture{paths: map[string]indexer.MutationTierState{ + path: {Generation: 1, AckAt: time.Now()}, + }}) + handlers := map[string]func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error){ + "read_file": s.handleReadFile, + "get_file_summary": s.handleGetFileSummary, + "get_editing_context": s.handleGetEditingContext, + } + for _, target := range []string{"target.go", path} { + t.Run(target, func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Name = tool + req.Params.Arguments = map[string]any{"path": target, "format": "json"} + res, err := s.wrapToolHandler(handlers[tool])(t.Context(), req) + require.NoError(t, err) + require.False(t, res.IsError, resultText(res)) + payload := resultJSON(t, res) + freshness, ok := payload["freshness"].(map[string]any) + require.True(t, ok, "pending file read lacks freshness: %s", resultText(res)) + require.Equal(t, "pending", freshness["graph_freshness"]) + require.Equal(t, "file", freshness["freshness_scope"]) + require.EqualValues(t, 1, freshness["pending_file_count"]) + require.Equal(t, []any{"target.go"}, freshness["pending_files"]) + }) + } + }) + } +} + +func TestFreshnessResponseContractRequireFreshRefusalNamesSelectedTier(t *testing.T) { + for _, scope := range []string{"file", "relations", "derived"} { + t.Run(scope, func(t *testing.T) { + s, root := newRelationFixture(t) + s.SetWatcher(&watchedTierFixture{paths: map[string]indexer.MutationTierState{ + filepath.Join(root, "target.go"): {Generation: 1, AckAt: time.Now()}, + }}) + req := mcp.CallToolRequest{} + req.Params.Name = "read_file" + req.Params.Arguments = map[string]any{ + "path": "target.go", "require_fresh": true, "freshness_scope": scope, + "wait_deadline": time.Now().Add(50 * time.Millisecond).Format(time.RFC3339Nano), + } + res, err := s.wrapToolHandler(s.handleReadFile)(t.Context(), req) + require.NoError(t, err) + require.True(t, res.IsError, "pending %s tier must refuse require_fresh", scope) + require.Contains(t, resultText(res), "require_fresh: "+scope+": pending (1 files)") + }) + } +} diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 3c8dfd58b..77db98f83 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -1,15 +1,21 @@ package mcp import ( + "context" "encoding/json" + "fmt" + "maps" "os" "path/filepath" "slices" "strings" + "time" + wire "github.com/gortexhq/gcx-go" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graphview" "github.com/zzet/gortex/internal/indexer" ) @@ -484,10 +490,9 @@ func collectGraphFilePaths(v any) []string { return out } -// decorateResultWithFreshness attaches the freshness rider to a JSON-object -// tool response under the "freshness" key. Non-JSON-object payloads -// (GCX / TOON / arrays) are left untouched — a best-effort hint must never -// reshape a compact wire format the caller opted into. +// decorateResultWithFreshness merges freshness into JSON-object responses +// and renders an in-band label for other output modes. Freshness-free +// responses pass through unchanged. func decorateResultWithFreshness(res *mcp.CallToolResult, rider map[string]any) *mcp.CallToolResult { if len(rider) == 0 { return res @@ -498,12 +503,15 @@ func decorateResultWithFreshness(res *mcp.CallToolResult, rider map[string]any) } var asObj map[string]any if json.Unmarshal([]byte(text), &asObj) != nil { - return res + return renderFreshnessLine(res, rider) } - if _, exists := asObj["freshness"]; exists { - return res + if existing, ok := asObj["freshness"].(map[string]any); ok { + for key, value := range rider { + existing[key] = value + } + } else { + asObj["freshness"] = rider } - asObj["freshness"] = rider body, err := json.Marshal(asObj) if err != nil { return res @@ -511,6 +519,599 @@ func decorateResultWithFreshness(res *mcp.CallToolResult, rider map[string]any) return rebuildTextResult(res, string(body)) } +func renderFreshnessLine(res *mcp.CallToolResult, fields map[string]any) *mcp.CallToolResult { + state, _ := fields["graph_freshness"].(string) + scope, _ := fields["freshness_scope"].(string) + if state == "" { + if fields["stale"] != true && fields["missing"] != true && fields["index_frozen"] != true { + return res + } + state, scope = "pending", "file" + } + if state == "" { + return res + } + if scope == "" { + scope = "relations" + } + count := fields["pending_file_count"] + if count == nil { + count = 1 + } + line := fmt.Sprintf("%s: %s (%v files)", scope, state, count) + body, ok := singleTextContent(res) + if !ok || strings.HasPrefix(body, line+"\n") { + return res + } + if strings.HasPrefix(body, wire.Tag) { + body, _ = injectGCXHeaderMeta(body, fields) + head, rows, _ := strings.Cut(body, wire.RowSep) + if strings.HasPrefix(rows, wire.CommentPrefix+" "+line+wire.RowSep) { + return rebuildTextResult(res, body) + } + body = head + wire.RowSep + wire.CommentPrefix + " " + line + wire.RowSep + rows + } else { + body = line + "\n" + body + } + return rebuildTextResult(res, body) +} + +// freshnessFor is the single tier producer. Canonical paths are read from +// their watcher; checkout routes retain their own scoped receipt authority. +func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []string, snapshots ...[]*mutationReceipt) relationFreshness { + label := relationFreshness{scope: scope} + paths, watched := s.canonicalTierPaths(ctx) + var receipts []*mutationReceipt + if !watched { + receipts = s.laggingMutationReceipts(ctx) + } + startup := s.multiIndexer != nil && s.multiIndexer.RelationsPending() + var snapshot toolFreshnessSnapshot + if len(snapshots) > 0 { + snapshot, _ = ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot) + } + gapCount := len(paths) + len(receipts) + len(snapshot.paths) + if len(snapshots) > 0 { + gapCount += len(snapshots[0]) + } + if gapCount == 0 { + if scope != "file" && (startup || snapshot.startup) { + label.state = "pending" + } + return label // idle reads do not resolve seeds or enumerate incoming edges + } + inScope := s.relationScope(ctx, ids, files) + type pathState struct { + state string + generation uint64 + } + states := make(map[string]pathState) + put := func(path, state string, generation uint64) { + if len(ids)+len(files) == 0 { + if prefix := baseNarrowedRepoPrefix(requestViewFromContext(ctx)); prefix != "" && !strings.HasPrefix(s.repoRelative(path), prefix+"/") { + return + } + } + if len(ids)+len(files) > 0 && !inScope(path) { + return + } + key := s.repoRelative(path) + if prior, exists := states[key]; !exists || generation >= prior.generation { + states[key] = pathState{state, generation} + } + } + putTiers := func(path string, tiers indexer.MutationTierState) { + at := tiers.RelationsAt + switch scope { + case "file": + at = tiers.FileGraphAt + case "derived": + at = tiers.DerivedAt + } + state := "" + if tiers.Failed { + state = "failed" + } else if at.IsZero() || scope == "derived" && tiers.Partial { + state = "pending" + } + if state != "" { + put(path, state, tiers.Generation) + } + } + if len(snapshots) > 0 { + if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { + for path, tiers := range snapshot.paths { + putTiers(path, tiers) + } + } + for _, receipt := range snapshots[0] { + state := "pending" + if outcome := receipt.outcome(true); !outcome.Pending && (outcome.Err != nil || !outcome.Reindexed) { + state = "failed" + } + put(receipt.path, state, receipt.generation) + } + } + if watched { + for path, tiers := range paths { + putTiers(path, tiers) + } + } else { + for _, receipt := range receipts { + outcome := receipt.outcome(true) + if !outcome.Pending && outcome.Err == nil && outcome.Reindexed { + continue // a receipt can finish between selection and this read + } + state := "failed" + if outcome.Pending { + state = "pending" + } + put(receipt.path, state, receipt.generation) + } + } + for path, state := range states { + label.files = append(label.files, path) + if label.state != "failed" { + label.state = state.state + } + } + slices.Sort(label.files) + if len(snapshots) > 0 { + if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { + startup = startup || snapshot.startup + } + } + if label.state == "" && scope != "file" && startup { + label.state = "pending" + } + return label +} + +func (s *Server) canonicalTierPaths(ctx context.Context) (map[string]indexer.MutationTierState, bool) { + watcher, watched := s.currentWatcher().(interface { + TierPaths() map[string]indexer.MutationTierState + }) + view := requestViewFromContext(ctx) + if !watched || view != nil && view.readsOwnCheckout() { + return nil, false + } + return watcher.TierPaths(), true +} + +type toolFreshnessSnapshotKey struct{} +type toolFreshnessSnapshot struct { + receipts []*mutationReceipt + paths map[string]indexer.MutationTierState + startup bool +} + +func (s *Server) captureToolFreshness(ctx context.Context) context.Context { + receipts := s.laggingMutationReceipts(ctx) + paths, _ := s.canonicalTierPaths(ctx) + startup := s.multiIndexer != nil && s.multiIndexer.RelationsPending() + if len(receipts) == 0 && len(paths) == 0 && !startup { + if _, exists := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); exists { + return context.WithValue(ctx, toolFreshnessSnapshotKey{}, toolFreshnessSnapshot{}) + } + return ctx + } + return context.WithValue(ctx, toolFreshnessSnapshotKey{}, toolFreshnessSnapshot{receipts: receipts, paths: paths, startup: startup}) +} + +func freshnessTargets(name string, req mcp.CallToolRequest) (scope string, ids, files []string) { + if file := targetRepoFileRaw(name, req); file != "" { + if (name == "get_file_summary" || name == "get_editing_context") && requestBoolArg(&req, requireFreshArgName) { + return "relations", nil, []string{file} + } + return "file", nil, []string{file} + } + switch name { + case "search_symbols", "search_text": + // A query is not a file identity. An explicit tier answer for these + // tools covers the selected canonical repository's whole graph. + if requestBoolArg(&req, requireFreshArgName) && !isCompact(req) { + return "relations", nil, nil + } + case "verify_change": + var changes []struct { + SymbolID string `json:"symbol_id"` + } + if json.Unmarshal([]byte(req.GetString("changes", "")), &changes) == nil { + for _, change := range changes { + if change.SymbolID != "" { + ids = append(ids, change.SymbolID) + } + } + } + if len(ids) > 0 { + return "relations", ids, nil + } + case "get_edit_plan", "explain_change_impact", "check_guards": + for _, id := range strings.Split(req.GetString("ids", ""), ",") { + if id = strings.TrimSpace(id); id != "" { + ids = append(ids, id) + } + } + if len(ids) > 0 { + return "relations", ids, nil + } + case "get_callers", "find_usages", "get_call_chain", "trace_call_chain", "get_dependencies", "get_dependents", "find_implementations", "walk_graph": + for _, key := range []string{"id", "symbol_id", "start_id"} { + if id := req.GetString(key, ""); id != "" { + ids = append(ids, id) + } + } + if len(ids) > 0 { + return "relations", ids, nil + } + } + return "", nil, nil +} + +func (s *Server) decorateToolFreshness(ctx context.Context, name string, req mcp.CallToolRequest, res *mcp.CallToolResult) *mcp.CallToolResult { + scope, ids, files := freshnessTargets(name, req) + var label relationFreshness + if scope != "" { + if selected := takeRequestFreshness(&req).scope; selected != "" { + scope = selected + } + label = s.freshnessFor(ctx, scope, ids, files) + if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { + label = s.freshnessFor(ctx, scope, ids, files, snapshot.receipts) + } + if label.state != "" { + fields := make(map[string]any) + label.apply(fields) + fields["freshness_scope"] = scope + res = decorateResultWithFreshness(res, fields) + } + } + beforeFileRiders := res + if rider := s.freshnessRiderFor(name, req); rider != nil { + res = decorateResultWithFreshness(res, rider) + } + if isFreshnessListTool(name) { + res = s.decorateListResultWithFreshness(res) + } + // A disk-drift or missing-file rider must not be joined to a fresh claim. + // Keep the producer's internal zero state unchanged; only an opted-in + // successful response renders its explicit equivalent. + if scope != "" && label.state == "" && res != nil && !res.IsError && res == beforeFileRiders { + label = s.explicitFreshness(ctx, req, label) + if label.state != "" { + fields := make(map[string]any) + label.apply(fields) + res = decorateResultWithFreshness(res, fields) + } + } + return fitToolFreshnessBudget(ctx, req, res) +} + +func (s *Server) explicitFreshness(ctx context.Context, req mcp.CallToolRequest, label relationFreshness) relationFreshness { + if label.state != "" || !requestBoolArg(&req, requireFreshArgName) || isCompact(req) { + return label + } + // Absence of a local receipt is not proof for another checkout, a source + // fallback, an immutable view, or an unwatched graph. Route freshness is + // a different claim and cannot provide this tier authority. + if _, watched := s.canonicalTierPaths(ctx); watched { + label.state = "unknown" + witness, ok := ctx.Value(explicitFreshnessPinKey{}).(*explicitFreshnessWitness) + if !ok || witness.watcher != s.currentWatcher() || !slices.Equal(witness.prefixes, s.canonicalFreshnessPrefixes(ctx)) { + return label + } + authority, tracked := s.currentWatcher().(interface{ TierAuthority(string) bool }) + if !tracked { + return label + } + inScope := func(file string) bool { + return slices.ContainsFunc(witness.prefixes, func(prefix string) bool { return strings.HasPrefix(file, prefix+"/") }) + } + _, ids, files := freshnessTargets(req.Params.Name, req) + for _, id := range ids { + file := graph.IDFile(id) + if file == "" || !inScope(file) { + return label + } + } + for _, file := range files { + if !inScope(s.graphRelPath(ctx, file)) { + return label + } + } + for i, pin := range witness.pins { + root, known := s.multiIndexer.RepoRoot(witness.prefixes[i]) + if !known || root != witness.roots[i] || s.multiIndexer.GetIndexer(witness.prefixes[i]) != witness.owners[i] || !authority.TierAuthority(witness.prefixes[i]) || pin.ValidateAcceptedCurrent() != nil { + return label + } + } + label.state = "fresh" + } + return label +} + +type explicitFreshnessPinKey struct{} + +type explicitFreshnessWitness struct { + watcher watcherHistory + prefixes []string + pins []*graphview.BasePin + owners []*indexer.Indexer + roots []string +} + +func (s *Server) canonicalFreshnessPrefixes(ctx context.Context) []string { + view := requestViewFromContext(ctx) + if OverlayViewFromContext(ctx) != nil || view != nil && (view.readsOwnCheckout() || view.rider != nil && !view.routeless && !view.rider.Exact) { + return nil + } + if prefix := baseNarrowedRepoPrefix(view); prefix != "" { + return []string{prefix} + } + if view != nil && view.reader != nil || s.multiIndexer == nil { + return nil + } + // AUTO can serve the unnarrowed shared corpus. Witness every canonical + // owner that reader may consult; do not substitute a BASE selector or a + // narrower query merely to obtain a positive freshness observation. + prefixes := s.multiIndexer.RepoPrefixes() + slices.Sort(prefixes) + return prefixes +} + +// Capture accepted source witnesses for the actual canonical reader before +// the read. Projection, editor layers and other checkout/ref readers retain +// their existing scope and cannot borrow these canonical publication facts. +func (s *Server) captureExplicitFreshness(ctx context.Context, req mcp.CallToolRequest) (context.Context, func()) { + if !requestBoolArg(&req, requireFreshArgName) || isCompact(req) || s.materializer == nil || s.materializer.Leases == nil || s.multiIndexer == nil { + return ctx, nil + } + prefixes := s.canonicalFreshnessPrefixes(ctx) + authority, tracked := s.currentWatcher().(interface{ TierAuthority(string) bool }) + if len(prefixes) == 0 || !tracked { + return ctx, nil + } + witness := &explicitFreshnessWitness{watcher: s.currentWatcher(), prefixes: prefixes} + release := func() { + for _, pin := range witness.pins { + pin.Release() + } + } + for _, prefix := range prefixes { + owner := s.multiIndexer.GetIndexer(prefix) + root, known := s.multiIndexer.RepoRoot(prefix) + if owner == nil || !known || root == "" || !authority.TierAuthority(prefix) { + release() + return ctx, nil + } + pin := s.materializer.Leases.AcquireBaseCorpus(prefix) + witness.pins = append(witness.pins, pin) + witness.owners = append(witness.owners, owner) + witness.roots = append(witness.roots, root) + if pin.ValidateAcceptedCurrent() != nil { + release() + return ctx, nil + } + } + return context.WithValue(ctx, explicitFreshnessPinKey{}, witness), release +} + +type toolFreshnessBudgetKey struct{} + +// Reserve the rider before the existing per-shape fitters choose rows. The +// original cap remains request-local so a late publication gap is fitted too. +func (s *Server) reserveToolFreshnessBudget(ctx context.Context, name string, req mcp.CallToolRequest) (context.Context, mcp.CallToolRequest) { + if _, reserved := ctx.Value(toolFreshnessBudgetKey{}).(int); reserved { + return ctx, req + } + budget := effectiveBudget(req) + if budget <= 0 { + return ctx, req + } + scope, ids, files := freshnessTargets(name, req) + if scope == "" { + return ctx, req + } + if requested := takeRequestFreshness(&req).scope; requested != "" { + scope = requested + } + label := s.freshnessFor(ctx, scope, ids, files) + if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { + label = s.freshnessFor(ctx, scope, ids, files, snapshot.receipts) + } + label = s.explicitFreshness(ctx, req, label) + if label.state == "" { + return ctx, req + } + reserve := len(label.line()) + 1 + if !isCompact(req) { + fields := make(map[string]any) + label.apply(fields) + encoded, _ := json.Marshal(fields) + reserve += 2*len(encoded) + 64 // JSON block or escaped GCX header metadata + } + args := maps.Clone(req.GetArguments()) + if args == nil { + args = make(map[string]any) + } + args["max_bytes"] = max(1, budget-reserve) + req.Params.Arguments, req.Params.RawArguments = args, nil + return context.WithValue(ctx, toolFreshnessBudgetKey{}, budget), req +} + +func fitToolFreshnessBudget(ctx context.Context, req mcp.CallToolRequest, res *mcp.CallToolResult) *mcp.CallToolResult { + budget, reserved := ctx.Value(toolFreshnessBudgetKey{}).(int) + if !reserved { + budget = effectiveBudget(req) + } + body, ok := singleTextContent(res) + if !ok || budget <= 0 || len(body) <= budget { + return res + } + var fields map[string]any + if json.Unmarshal([]byte(body), &fields) == nil && fields != nil { + rider, labelled := fields["freshness"].(map[string]any) + if !labelled { + return res + } + fitted, _ := applyBudget(fields, budget) + encoded, err := json.Marshal(fitted) + if err == nil && len(encoded) <= budget { + return rebuildTextResult(res, string(encoded)) + } + // An indivisible scalar can leave no answer rows to trim. Keep an + // explicit partial response, including the full pending-file count. + minimal := map[string]any{"freshness": maps.Clone(rider), budgetTruncatedKey: true} + minimalRider := minimal["freshness"].(map[string]any) + delete(minimalRider, "pending_files") + encoded, err = json.Marshal(minimal) + if err == nil && len(encoded) <= budget { + return rebuildTextResult(res, string(encoded)) + } + return mcp.NewToolResultError("freshness exceeds max_bytes") + } + line, _, _ := strings.Cut(body, "\n") + labelled := strings.HasPrefix(line, "file: ") || strings.HasPrefix(line, "relations: ") || strings.HasPrefix(line, "derived: ") + if strings.HasPrefix(body, wire.Tag) { + trimmed, _ := trimGCXBytes([]byte(body), budget) + if len(trimmed) <= budget { + return rebuildTextResult(res, string(trimmed)) + } + return mcp.NewToolResultError("freshness exceeds max_bytes") + } + if !labelled { + return res + } + if isCompact(req) || req.GetString("format", "") == "text" { + return rebuildTextResult(res, line+"\n"+trimTextToBudget(strings.TrimPrefix(body, line+"\n"), max(0, budget-len(line)-1))) + } + // TOON has no generic byte-cut operation. A newly admitted gap may have + // outgrown the reservation; return a labelled refusal rather than a torn table. + if len(line) <= budget { + return mcp.NewToolResultError(line) + } + return mcp.NewToolResultError("freshness exceeds max_bytes") +} + +func (s *Server) awaitWatchedFreshness(ctx context.Context, name string, req mcp.CallToolRequest, freshness requestFreshness) *mcp.CallToolResult { + view := requestViewFromContext(ctx) + if !freshness.requireFresh || view != nil && view.readsOwnCheckout() { + return nil + } + if _, ok := s.currentWatcher().(interface { + TierPaths() map[string]indexer.MutationTierState + }); !ok { + return nil + } + scope, ids, files := freshnessTargets(name, req) + if scope == "" { + return nil + } + if freshness.scope != "" { + scope = freshness.scope + } + started := time.Now() + deadline := freshness.effectiveDeadline(started, ctx) + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + label := s.freshnessFor(ctx, scope, ids, files) + if label.state == "" { + annotateRequestFreshness(view, &requestFreshnessOutcome{fresh: true, deadline: deadline}, started) + return nil + } + if label.state == "failed" || !time.Now().Before(deadline) { + label.waited = time.Since(started) + return label.requireFreshError() + } + select { + case <-ticker.C: + case <-ctx.Done(): + label.waited = time.Since(started) + return label.requireFreshError() + } + } +} + +func (f relationFreshness) apply(result map[string]any) { + if f.state == "" { + return + } + if f.state != "fresh" { + result["complete"] = false + } + result["graph_freshness"] = f.state + scope := f.scope + if scope == "" { + scope = "relations" + } + result["freshness_scope"] = scope + result["pending_file_count"] = len(f.files) + result["pending_files"] = f.files[:min(maxLabelledPendingFiles, len(f.files))] + if f.state == "failed" { + result["recovery_hint"] = mutationFreshnessRecoveryHint() + } +} + +func (f relationFreshness) decorate(res *mcp.CallToolResult) *mcp.CallToolResult { + if f.state == "" || res == nil || res.IsError { + return res + } + body, ok := singleTextContent(res) + if !ok || strings.HasPrefix(strings.TrimSpace(body), "{") { + return res + } + fields := make(map[string]any) + f.apply(fields) + return renderFreshnessLine(res, fields) +} + +func appendSourceFreshness(fields map[string]any, view *requestView) { + fields["graph_freshness"] = "unknown" + if view.sourceGraphPending { + fields["graph_freshness"] = "pending" + } +} + +func attachReceiptTiers(payload map[string]any, receipt *mutationReceipt) { + if receipt == nil || receipt.ticket == nil { + return + } + attachMutationTierState(payload, receipt.ticket.TierState()) +} + +// Mutation status and source ACKs render the same immutable producer snapshot. +func attachMutationTierState(payload map[string]any, tiers indexer.MutationTierState) { + for _, key := range []string{"tier_state", "ack_at", "file_graph_at", "relations_at", "derived_at"} { + delete(payload, key) + } + if tiers.AckAt.IsZero() { + return + } + for key, at := range map[string]time.Time{ + "ack_at": tiers.AckAt, "file_graph_at": tiers.FileGraphAt, + "relations_at": tiers.RelationsAt, "derived_at": tiers.DerivedAt, + } { + if !at.IsZero() { + payload[key] = at.UTC().Format(time.RFC3339Nano) + } + } + states := make(map[string]string, 3) + for name, at := range map[string]time.Time{"file_graph": tiers.FileGraphAt, "relations": tiers.RelationsAt, "derived": tiers.DerivedAt} { + state := "pending" + if !at.IsZero() { + state = "fresh" + } + if tiers.Failed { + state = "failed" + } else if name == "derived" && tiers.Partial { + state = "partial" + } + states[name] = state + } + payload["tier_state"] = states +} + // shortFreshSHA trims a git SHA to 12 chars for the rider. func shortFreshSHA(sha string) string { if len(sha) > 12 { @@ -518,3 +1119,20 @@ func shortFreshSHA(sha string) string { } return sha } + +// File exactness is physical projection evidence, independent of the ticket's +// graph/relations/derived producer. Copy the map so cached ACK snapshots remain +// immutable when a later replay observes a replacement or invalidation. +func attachMutationFileTier(payload map[string]any, exact bool) { + states := make(map[string]string, 4) + if previous, ok := payload["tier_state"].(map[string]string); ok { + for key, value := range previous { + states[key] = value + } + } + states["file"] = "pending" + if exact { + states["file"] = "fresh" + } + payload["tier_state"] = states +} diff --git a/internal/mcp/freshness_rider_test.go b/internal/mcp/freshness_rider_test.go index 213148f8c..36a2a9d99 100644 --- a/internal/mcp/freshness_rider_test.go +++ b/internal/mcp/freshness_rider_test.go @@ -54,10 +54,19 @@ func TestDecorateResultWithFreshness(t *testing.T) { require.Equal(t, float64(1), obj["x"]) require.NotNil(t, obj["freshness"]) - // Non-JSON-object payload (GCX/TOON) is left untouched. - got2 := decorateResultWithFreshness(mcp.NewToolResultText("GCX1 tool=foo\nrow1"), rider) - text2, _ := singleTextContent(got2) - require.Equal(t, "GCX1 tool=foo\nrow1", text2) + // The shared renderer labels compact formats too, retaining their rows. + for _, test := range []struct{ body, want string }{ + {"GCX1 tool=foo\nrow1", "GCX1 tool=foo file=a.go stale=true\n# file: pending (1 files)\nrow1"}, + {"items[1]{id}:\n one", "file: pending (1 files)\nitems[1]{id}:\n one"}, + {"row1", "file: pending (1 files)\nrow1"}, + } { + labelled := decorateResultWithFreshness(mcp.NewToolResultText(test.body), rider) + text, _ := singleTextContent(labelled) + require.Equal(t, test.want, text) + idle := decorateResultWithFreshness(mcp.NewToolResultText(test.body), nil) + idleText, _ := singleTextContent(idle) + require.Equal(t, test.body, idleText, "an idle response remains byte-identical") + } // Empty rider is a no-op. got3 := decorateResultWithFreshness(mcp.NewToolResultText(`{"x":1}`), nil) diff --git a/internal/mcp/freshness_tiers_test.go b/internal/mcp/freshness_tiers_test.go new file mode 100644 index 000000000..134883879 --- /dev/null +++ b/internal/mcp/freshness_tiers_test.go @@ -0,0 +1,168 @@ +package mcp + +import ( + "context" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/indexer" +) + +func TestRestartLostReceiptsReturnWithoutWaiting(t *testing.T) { + for _, key := range []string{"receipt", "mutation_id"} { + t.Run(key, func(t *testing.T) { + s := newMutationServer() + req := mcp.CallToolRequest{} + req.Params.Name = "mutation_status" + req.Params.Arguments = map[string]any{key: "mutation-100", "require_fresh": true, + "wait_deadline": time.Now().Add(time.Minute).Format(time.RFC3339Nano)} + res, err := s.handleMutationStatus(context.Background(), req) + require.NoError(t, err) + payload := resultJSON(t, res) + require.Equal(t, "lost", payload["state"]) + require.Equal(t, false, payload["found"]) + require.Equal(t, true, payload["graph_status_terminal"]) + require.NotContains(t, payload, "graph_freshness") + }) + } +} + +func TestFreshnessFieldsHaveOneProducer(t *testing.T) { + entries, err := os.ReadDir(".") + require.NoError(t, err) + keys := map[string]bool{"graph_freshness": true, "file_graph_at": true, "relations_at": true, "derived_at": true} + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") || name == "freshness_rider.go" { + continue + } + file, err := parser.ParseFile(token.NewFileSet(), name, nil, 0) + require.NoError(t, err) + ast.Inspect(file, func(node ast.Node) bool { + if literal, ok := node.(*ast.BasicLit); ok && literal.Kind == token.STRING { + key, _ := strconv.Unquote(literal.Value) + require.False(t, keys[key], "%s produces tier field %s outside the shared rider", name, key) + } + return true + }) + } +} + +type watchedTierFixture struct { + mutationTestWatcher + mu sync.RWMutex + paths map[string]indexer.MutationTierState +} + +func (w *watchedTierFixture) TierPaths() map[string]indexer.MutationTierState { + w.mu.RLock() + defer w.mu.RUnlock() + copyPaths := make(map[string]indexer.MutationTierState, len(w.paths)) + for path, state := range w.paths { + copyPaths[path] = state + } + return copyPaths +} + +func TestRequireFreshWaitsForRelationsOnWatchedPrimary(t *testing.T) { + s, root := newRelationFixture(t) + path := filepath.Join(root, "caller.go") + watcher := &watchedTierFixture{paths: map[string]indexer.MutationTierState{ + path: {Generation: 1, AckAt: time.Now(), FileGraphAt: time.Now()}, + }} + s.SetWatcher(watcher) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": targetID(t, s), "require_fresh": true, "freshness_scope": "relations"} + finished := make(chan *mcp.CallToolResult, 1) + go func() { + finished <- s.awaitWatchedFreshness(t.Context(), req.Params.Name, req, takeRequestFreshness(&req)) + }() + select { + case <-finished: + t.Fatal("file publication incorrectly certified pending relations") + case <-time.After(35 * time.Millisecond): + } + watcher.mu.Lock() + state := watcher.paths[path] + state.RelationsAt = time.Now() + watcher.paths[path] = state + watcher.mu.Unlock() + select { + case refusal := <-finished: + require.Nil(t, refusal, "relations are visible even while derived is unfinished") + case <-time.After(time.Second): + t.Fatal("relations publication did not release the primary wait") + } + require.Empty(t, s.freshnessFor(t.Context(), "relations", []string{targetID(t, s)}, nil).state) + require.Equal(t, "pending", s.freshnessFor(t.Context(), "derived", []string{targetID(t, s)}, nil).state) +} + +func TestFailedPatchLabelsOnlyItsFile(t *testing.T) { + s, root := newRelationFixture(t) + s.SetWatcher(&watchedTierFixture{paths: map[string]indexer.MutationTierState{ + filepath.Join(root, "other.go"): {Generation: 1, Failed: true}, + }}) + require.Empty(t, s.freshnessFor(t.Context(), "relations", []string{targetID(t, s)}, nil).state) + label := s.freshnessFor(t.Context(), "relations", nil, []string{"other.go"}) + require.Equal(t, "failed", label.state) + require.Equal(t, []string{"other.go"}, label.files) +} + +func TestVerifyChangeCompactCarriesFreshnessFirstLine(t *testing.T) { + s, root := newRelationFixture(t) + s.SetWatcher(&watchedTierFixture{paths: map[string]indexer.MutationTierState{ + filepath.Join(root, "caller.go"): {Generation: 1, AckAt: time.Now()}, + }}) + req := mcp.CallToolRequest{} + req.Params.Name = "verify_change" + req.Params.Arguments = map[string]any{"changes": `[{"symbol_id":"` + targetID(t, s) + `","new_signature":"func Target()"}]`, "compact": true} + result, err := s.wrapToolHandler(s.handleVerifyChange)(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, resultText(result)) + require.True(t, strings.HasPrefix(resultText(result), "relations: pending (1 files)\n"), resultText(result)) +} + +func TestIdleFreshnessIsANoOp(t *testing.T) { + s, _ := newRelationFixture(t) + s.SetWatcher(&watchedTierFixture{}) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": targetID(t, s)} + for _, body := range []string{`{"nodes":[]}`, "no callers", "callers[0]:"} { + original := mcp.NewToolResultText(body) + actual := s.decorateToolFreshness(t.Context(), req.Params.Name, req, original) + require.Same(t, original, actual) + require.Equal(t, body, resultText(actual)) + } +} + +func TestFreshnessRiderLabelsEveryOutputMode(t *testing.T) { + rider := map[string]any{"freshness_scope": "relations", "graph_freshness": "pending", + "pending_file_count": 2, "pending_files": []string{"caller.go", "target.go"}} + for _, body := range []string{"clean: checked 2 callers", "callers[1]{name}:\n Caller", "[1,2]"} { + res := decorateResultWithFreshness(mcp.NewToolResultText(body), rider) + require.Equal(t, "relations: pending (2 files)\n"+body, resultText(res)) + } + jsonResult := decorateResultWithFreshness(mcp.NewToolResultText(`{"ok":true}`), rider) + require.Equal(t, "pending", resultJSON(t, jsonResult)["freshness"].(map[string]any)["graph_freshness"]) +} + +func TestMutationIDsAreEpochSeeded(t *testing.T) { + // Counters beginning at one can reuse a previous process's receipt after a + // restart, turning an explicitly lost write into an unrelated live write. + const plausibleEpoch = uint64(1_000_000_000_000_000_000) + require.Greater(t, mutationReceiptSequence.Load(), plausibleEpoch) + require.Greater(t, mutationCommitSequence.Load(), plausibleEpoch) +} diff --git a/internal/mcp/guide.go b/internal/mcp/guide.go index e14d20036..8d7910653 100644 --- a/internal/mcp/guide.go +++ b/internal/mcp/guide.go @@ -127,6 +127,10 @@ File reads of a live checkout verify current source bytes independently of relat The session CWD selects an automatically discovered checkout overlay. Do not call track for an ordinary worktree: explicit tracking means the user requested a dedicated logical graph. Overall is one coherent view—the selected overlay plus its designated primary—not a union of branches. Overlay data only wins for duplicate logical identities; tombstones hide deleted base identities. +A readback_exact:true acknowledgement means continue without polling. Check graph_freshness before acting on callers, usages, impact, or a rename plan. Before rename, delete, or signature changes, use require_fresh:true with freshness_scope:"relations" and wait_deadline. A symbol whose file is pending must not be treated as dead. Watched primary checkouts wait for the requested file, relations, or derived tier; immutable ref/commit views cannot advance. + +With require_fresh, noncompact canonical graph reads report graph_freshness:"fresh" only when the selected tier's accepted sources remain current throughout the read. Unknown means that proof is unavailable; route-level fresh is a separate claim. Compact output has no explicit positive graph-freshness contract. + Every response carries freshness metadata. ` + "`exact:false`" + ` means the requested view was not served, and every fallback is read-only. Set ` + "`require_exact:true`" + ` to reject fallback, ` + "`require_fresh:true`" + ` to wait for current filesystem state, and bound waiting with an absolute RFC3339 ` + "`wait_deadline`" + ` (a malformed or past deadline is refused). ` + "`require_fresh`" + ` on graph-dependent operations waits for the selected checkout's route to reach the working copy as the coordinator samples it now, and reports what the wait did: ` + "`fresh`" + `, ` + "`waited_ms`" + `, ` + "`wait_deadline`" + `, and ` + "`fresh_reason`" + ` when it is false. ` + "`fresh:true`" + ` is reported only when the answer is read through the very route the wait published — the same checkout, served exactly, at the same route epoch; anything else answers ` + "`fresh:false`" + `. The ` + "`fresh_reason`" + ` vocabulary is ` + "`deadline_exceeded`" + ` (your bound expired with the route still behind the working copy), ` + "`interrupted`" + ` (the request ended first), ` + "`refresh_admission_abandoned`" + ` (the coordinator gave up on a bound of its own before admitting any refresh, so a longer wait_deadline is not the fix), ` + "`coordinator_unavailable`" + ` (this server can publish no checkout generation at all), ` + "`checkout_root_changed`" + ` and ` + "`checkout_refresh_stopped`" + ` (this one checkout moved, or its coordinator stopped), ` + "`publication_failed`" + `, ` + "`route_withdrawn`" + ` (publication succeeded and something else answered: a base fallback, another checkout, or a route that moved again), ` + "`committed_base_advance_unimplemented`" + `, and ` + "`wait_target_unavailable`" + ` (the route could not be resolved to wait on, or read back afterwards — a retry may succeed). diff --git a/internal/mcp/impact_waits.go b/internal/mcp/impact_waits.go new file mode 100644 index 000000000..38e5f08e0 --- /dev/null +++ b/internal/mcp/impact_waits.go @@ -0,0 +1,73 @@ +package mcp + +import ( + "context" + "sync" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/reach" +) + +type impactWaitPhase struct { + observed, attempted, acquired, detached int + total time.Duration +} + +type impactCoordinationWaits struct { + mu sync.Mutex + gate, resolve impactWaitPhase +} + +func withImpactCoordinationWaits(ctx context.Context) (context.Context, *impactCoordinationWaits) { + waits := new(impactCoordinationWaits) + return reach.WithCoordinationWaitObserver(ctx, waits.observe), waits +} + +func (w *impactCoordinationWaits) observe(wait reach.CoordinationWait) { + w.mu.Lock() + defer w.mu.Unlock() + var phase *impactWaitPhase + switch wait.Phase { + case "gate_wait": + phase = &w.gate + case "resolve_mutex_wait": + phase = &w.resolve + default: + return + } + phase.observed++ + if !wait.Attempted { + phase.detached++ + return + } + phase.attempted++ + phase.total += wait.Duration + if wait.Acquired { + phase.acquired++ + } +} + +func impactWaitFields(phase impactWaitPhase) map[string]any { + state := "not_observed" + var ms any + switch { + case phase.attempted > phase.acquired: + state = "censored" + ms = float64(phase.total) / float64(time.Millisecond) + case phase.attempted > 0: + state = "observed" + ms = float64(phase.total) / float64(time.Millisecond) + case phase.detached > 0: + state = "not_applicable_detached" + } + return map[string]any{"state": state, "total_ms": ms, "observations": phase.observed, "attempted": phase.attempted, "acquired": phase.acquired, "detached": phase.detached} +} + +// Timing travels in the existing format-independent metadata envelope. The +// selected JSON/TOON/GCX payload is unchanged; no status registry is retained. +func attachImpactCoordinationWaits(result *mcp.CallToolResult, w *impactCoordinationWaits) *mcp.CallToolResult { + w.mu.Lock() + defer w.mu.Unlock() + return mergeResultMeta(result, map[string]any{"reach_coordination": map[string]any{"gate_wait": impactWaitFields(w.gate), "resolve_mutex_wait": impactWaitFields(w.resolve)}}) +} diff --git a/internal/mcp/impact_waits_test.go b/internal/mcp/impact_waits_test.go new file mode 100644 index 000000000..922870733 --- /dev/null +++ b/internal/mcp/impact_waits_test.go @@ -0,0 +1,60 @@ +package mcp + +import ( + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/reach" +) + +func TestImpactCoordinationMetadataPreservesPayloadAndMissingStates(t *testing.T) { + waits := new(impactCoordinationWaits) + waits.observe(reach.CoordinationWait{Phase: "gate_wait", Duration: 12 * time.Millisecond, Attempted: true, Acquired: true}) + waits.observe(reach.CoordinationWait{Phase: "resolve_mutex_wait", Acquired: true}) + original := `{"complete":true,"by_depth":{}}` + result := attachImpactCoordinationWaits(mcp.NewToolResultText(original), waits) + if got := result.Content[0].(mcp.TextContent).Text; got != original { + t.Fatalf("wait metadata changed semantic payload: %s", got) + } + meta := result.Meta.AdditionalFields["reach_coordination"].(map[string]any) + gate := meta["gate_wait"].(map[string]any) + resolve := meta["resolve_mutex_wait"].(map[string]any) + if gate["state"] != "observed" || gate["total_ms"] != float64(12) { + t.Fatalf("gate wait lost: %v", gate) + } + if resolve["state"] != "not_applicable_detached" || resolve["total_ms"] != nil { + t.Fatalf("detached mutex became a measured zero: %v", resolve) + } + missing := impactWaitFields(impactWaitPhase{}) + if missing["state"] != "not_observed" || missing["total_ms"] != nil { + t.Fatalf("unobserved wait became zero: %v", missing) + } + interrupted := impactWaitFields(impactWaitPhase{observed: 1, attempted: 1, total: time.Second}) + if interrupted["state"] != "censored" { + t.Fatalf("interrupted wait claimed complete: %v", interrupted) + } +} + +func TestImpactCoordinationHandlerForwardsRealLookupWaits(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "repo/a.go", RepoPrefix: "repo"}) + s := &Server{graph: g, engine: query.NewEngine(g), session: newSessionState()} + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Name: "explain_change_impact", Arguments: map[string]any{"ids": "repo/a.go::A", "format": "json"}}} + result, err := s.handleEnhancedChangeImpact(t.Context(), req) + if err != nil || result == nil || result.IsError { + t.Fatalf("impact failed: %v %+v", err, result) + } + if result.Meta == nil { + t.Fatal("handler dropped actual wait observations") + } + fields, _ := result.Meta.AdditionalFields["reach_coordination"].(map[string]any) + for _, name := range []string{"gate_wait", "resolve_mutex_wait"} { + phase, _ := fields[name].(map[string]any) + if phase["state"] != "observed" || phase["attempted"] != 1 || phase["acquired"] != 1 { + t.Fatalf("%s did not reflect actual lookup: %v", name, phase) + } + } +} diff --git a/internal/mcp/index_file_failures.go b/internal/mcp/index_file_failures.go index 463074df1..1975a6b0f 100644 --- a/internal/mcp/index_file_failures.go +++ b/internal/mcp/index_file_failures.go @@ -329,5 +329,43 @@ func (s *Server) refreshIndexHealthFileFailures(ctx context.Context, baseline ma result["index_state_read_error"] = readErr.Error() result["recommendation"] = strings.TrimSpace("Index failure state could not be read; index completeness is unknown. " + readErr.Error() + ". " + recommendation) } + if section := pendingProjectionHealth(pendingFileProjections); section != nil { + result["pending_projection"] = section + } return result } + +// pendingProjectionHealth reports the pending projections: live entries and +// their estimated size, what publishing refused and why, and how many +// retired entries disagreed with the store. It is nil until the registry has +// done anything, and it reads only in-memory state. +func pendingProjectionHealth(p *pendingProjections) map[string]any { + snap := p.snap.Load() + p.mu.Lock() + refused := make(map[string]int64, len(p.refused)) + for reason, n := range p.refused { + refused[string(reason)] = n + } + p.mu.Unlock() + published := p.published.Load() + if len(snap.entries) == 0 && published == 0 && len(refused) == 0 { + return nil + } + tombstones := 0 + for _, e := range snap.entries { + if e.tombstone { + tombstones++ + } + } + return map[string]any{ + "enabled": p.enabled, + "files": len(snap.entries) - tombstones, + "tombstones": tombstones, + "bytes": snap.bytes, + "published": published, + "refused": refused, + "retired": p.retired.Load(), + "external_writes": p.externalWrites.Load(), + "projection_divergence": p.divergence.Load(), + } +} diff --git a/internal/mcp/keyed_response_wait_test.go b/internal/mcp/keyed_response_wait_test.go new file mode 100644 index 000000000..159a5ebff --- /dev/null +++ b/internal/mcp/keyed_response_wait_test.go @@ -0,0 +1,180 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" +) + +type keyedReplyResult struct { + result *mcplib.CallToolResult + err error +} + +// Keep the original graph wait open after the real physical commit. The +// duplicate must release its acquired path before waiting for that response. +func TestKeyedReplayWaitsForOriginalResponseOutsidePath(t *testing.T) { + for _, mode := range []string{"published", "cancelled waiter", "evicted receipt"} { + t.Run(mode, func(t *testing.T) { keyedReplayResponseHandoff(t, mode) }) + } +} + +func keyedReplayResponseHandoff(t *testing.T, mode string) { + path := filepath.Join(t.TempDir(), "keyed.txt") + require.NoError(t, os.WriteFile(path, []byte("x\n"), 0600)) + s := newMutationServer() + publication := make(chan indexer.MutationResult, 1) + var scheduled atomic.Int64 + s.SetWatcher(mutationTestWatcher{done: publication, generation: 1, scheduled: &scheduled}) + s.mutationReindexWait = time.Minute // an owned signal controls completion + complete := sync.OnceFunc(func() { + publication <- indexer.MutationResult{Reindexed: true, RequestedGeneration: 1, AppliedGeneration: 1} + close(publication) + }) + t.Cleanup(complete) + args := map[string]any{"path": path, "old_string": "x", "new_string": "x\ny", "mutation_id": "once"} + original := make(chan keyedReplyResult, 1) + go func() { + r, err := s.handleEditFile(t.Context(), editFileRequest(args)) + original <- keyedReplyResult{r, err} + }() + require.Eventually(t, func() bool { return scheduled.Load() == 1 }, 3*time.Second, time.Millisecond) + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + replayCtx, cancelReplay := context.WithCancel(ctx) + defer cancelReplay() + _, releasePath, err := acquireMutationPath(ctx, path) + require.NoError(t, err, "the original graph wait must release its physical path") + defer releasePath() + record, ok := s.mutationCommits.byMutationID("once") + require.True(t, ok) + record.mu.Lock() + releaseRecord := sync.OnceFunc(record.mu.Unlock) + defer releaseRecord() + require.Equal(t, mutationDiskCommitted, record.disk) + require.Nil(t, record.response, "the original result is deliberately not ready") + duplicate := make(chan keyedReplyResult, 1) + go func() { + r, err := s.handleEditFile(replayCtx, editFileRequest(args)) + duplicate <- keyedReplyResult{r, err} + }() + releasePath() + key := pathkey.MutationKey(path) + require.Eventually(t, func() bool { + mutationPathLocks.Lock() + defer mutationPathLocks.Unlock() + entry := mutationPathLocks.byPath[key] + return entry != nil && entry.refs == 1 && len(entry.token) == 0 + }, time.Second, time.Millisecond, "the duplicate must own the path and wait at the held record") + probe := make(chan error, 1) + go func() { + _, release, err := acquireMutationPath(ctx, path) + if err != nil { + probe <- err + return + } + // The independent probe can act only after the duplicate returned or + // released its path. Cancellation never targets the original caller. + switch mode { + case "cancelled waiter": + cancelReplay() + case "evicted receipt": + s.mutationCommits.mu.Lock() + s.mutationCommits.evictLocked(time.Now().Add(mutationCommitRetention + time.Second)) + s.mutationCommits.mu.Unlock() + } + complete() + release() + probe <- nil + }() + require.Eventually(t, func() bool { + mutationPathLocks.Lock() + defer mutationPathLocks.Unlock() + entry := mutationPathLocks.byPath[key] + return entry != nil && entry.refs == 2 + }, time.Second, time.Millisecond, "the independent path probe must queue behind the duplicate") + releaseRecord() + select { + case err := <-probe: + require.NoError(t, err, "a replay wait must not retain the path") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + var repeated, first keyedReplyResult + select { + case repeated = <-duplicate: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + select { + case first = <-original: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + require.NoError(t, first.err) + require.NoError(t, repeated.err) + require.False(t, first.result.IsError, "%s", resultText(first.result)) + firstBody := decodeFileOpsResult(t, first.result) + require.Equal(t, true, firstBody["reindexed"], "a duplicate's cancellation/eviction cannot cancel original publication") + if mode == "published" { + require.False(t, repeated.result.IsError, "%s", resultText(repeated.result)) + repeatedBody := decodeFileOpsResult(t, repeated.result) + require.Equal(t, true, repeatedBody["replayed"]) + require.Equal(t, firstBody["mutation_receipt"], repeatedBody["mutation_receipt"]) + require.Equal(t, firstBody["new_sha"], repeatedBody["new_sha"]) + } else { + require.True(t, repeated.result.IsError) + require.Contains(t, resultText(repeated.result), "already committed") + require.Contains(t, resultText(repeated.result), record.id) + } + require.Equal(t, int64(1), scheduled.Load(), "a replay cannot enqueue another publication") + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "x\ny\n", string(raw), "the physical edit occurs once") +} + +type unavailableKeyedReplyWatcher struct { + mutationTestWatcher + calls atomic.Int64 +} + +func (w *unavailableKeyedReplyWatcher) EnqueueToolMutation(context.Context, string) (*indexer.MutationTicket, error) { + w.calls.Add(1) + panic("owned publication failure after physical commit") +} + +func TestKeyedReplayTerminalUnavailableNeverWritesAgain(t *testing.T) { + path := filepath.Join(t.TempDir(), "terminal.txt") + require.NoError(t, os.WriteFile(path, []byte("x\n"), 0600)) + s := newMutationServer() + w := &unavailableKeyedReplyWatcher{} + s.SetWatcher(w) + args := map[string]any{"path": path, "old_string": "x", "new_string": "x\ny", "mutation_id": "terminal"} + var recovered any + func() { + defer func() { recovered = recover() }() + _, _ = s.handleEditFile(t.Context(), editFileRequest(args)) + }() + require.Equal(t, "owned publication failure after physical commit", recovered) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + result, err := s.handleEditFile(ctx, editFileRequest(args)) + require.NoError(t, err) + require.True(t, result.IsError) + require.Contains(t, resultText(result), "already committed") + require.Contains(t, resultText(result), "original handler finished") + require.NotContains(t, resultText(result), "deadline") + require.EqualValues(t, 1, w.calls.Load()) + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "x\ny\n", string(raw)) +} diff --git a/internal/mcp/move_file_observation.go b/internal/mcp/move_file_observation.go index 675241a1c..0edc90a22 100644 --- a/internal/mcp/move_file_observation.go +++ b/internal/mcp/move_file_observation.go @@ -19,11 +19,14 @@ type moveFileObservation struct { operationID, sourceID, targetID, role string moveSession, moveRequestKey string writeVerdict, directState string + backgroundState string + backgroundWait bool directClaimed bool readClaimed bool readState string readAssisted *moveDirectObservation direct *moveDirectObservation + physical weak.Pointer[mutationCommitRecord] } type moveDirectObservation struct { generation uint64 @@ -66,43 +69,143 @@ func (s *Server) beginMoveObservation(ctx context.Context, sourceID, targetID, s return out } -func (o *moveObservation) write(ack *editAcknowledgement, path string, content []byte) error { +func (o *moveObservation) write(ctx context.Context, s *Server, path string, content []byte) (*mutationCommitRecord, error) { + physical, err := s.writeMoveFile(ctx, "move_symbol", path, content) if o == nil { - return writeMoveFileObserved(ack, path, content) + return physical, err } - started := time.Now() - err := writeMoveFileObserved(ack, path, content) - returned := time.Now() record := o.records[path] if record == nil { - return err + return physical, err } - blob := "" - if err == nil { - blob = gitBlobSHA(content) - } // no existing fingerprint is produced by the move writer ledger := &o.server.mutationCommits ledger.mu.Lock() defer ledger.mu.Unlock() + if physical != nil { + physical.mu.Lock() + defer physical.mu.Unlock() + } record.mu.Lock() defer record.mu.Unlock() - if ledger.byID[record.id] != record || record.owner != o.server || record.kind != moveFileObservationCommit || record.moveFile == nil || record.key != o.id { - return err + if ledger.byID[record.id] != record || record.owner != s || record.kind != moveFileObservationCommit || record.moveFile == nil || record.key != o.id { + return physical, err } if !record.diskWriteStartedAt.IsZero() { - record.disk = mutationDiskFailed - record.errText = "unavailable_repeated_writer" - return err + record.disk, record.errText = mutationDiskFailed, "unavailable_repeated_writer" + return physical, err } - record.diskWriteStartedAt, record.committedAt = started, returned - if err != nil { + if err != nil || physical == nil { record.disk = mutationDiskFailed - record.errText = err.Error() - return err + if err != nil { + record.errText = err.Error() + } + return physical, err + } + if ledger.byID[physical.id] != physical || physical.owner != s || physical.kind != physicalMutationCommit || physical.acknowledgement == nil || physical.acknowledgement != record.acknowledgement || physical.absPath != path || physical.disk != mutationDiskCommitted { + record.disk, record.errText = mutationDiskFailed, "unavailable_physical_owner" + return physical, err + } + // This is one write event. Borrow its actual physical timestamps and + // sequence; no second disk lap, commit sequence or source authority. + record.diskWriteStartedAt, record.committedAt = physical.diskWriteStartedAt, physical.committedAt + record.disk, record.newSHA, record.bytesWritten, record.commitSeq = physical.disk, physical.newSHA, physical.bytesWritten, physical.commitSeq + record.moveFile.physical = weak.Make(physical) + physical.moveTimingOwner = weak.Make(record) + return physical, err +} + +// The caller holds ledger and the timing record. A weak association grants +// no lifetime: eviction, a foreign ACK/path/owner or a replaced link refuses. +func linkedMovePhysicalLocked(ledger *mutationCommitLedger, s *Server, record *mutationCommitRecord) *mutationCommitRecord { + if record.moveFile == nil { + return nil + } + physical := record.moveFile.physical.Value() + if physical == nil || physical == record { + return nil + } + physical.mu.RLock() + defer physical.mu.RUnlock() + if ledger.byID[record.id] != record || ledger.byID[physical.id] != physical || record.owner != s || physical.owner != s || record.kind != moveFileObservationCommit || physical.kind != physicalMutationCommit || physical.moveTimingOwner.Value() != record || record.acknowledgement == nil || physical.acknowledgement != record.acknowledgement || physical.absPath != record.absPath || physical.checkoutID != record.checkoutID || physical.checkoutIncarnation != record.checkoutIncarnation || physical.disk != mutationDiskCommitted || physical.commitSeq == 0 || physical.commitSeq != record.commitSeq || physical.newSHA == "" || physical.newSHA != record.newSHA || !physical.committedAt.Equal(record.committedAt) || !physical.diskWriteStartedAt.Equal(record.diskWriteStartedAt) { + return nil + } + return physical +} + +// A status witness adds no authority: it only authenticates the existing weak +// physical child across rendering, which refreshes and reacquires the ledger. +type movePhysicalStatusWitness struct { + ack *editAcknowledgement + path, blob, checkout, incarnation string + sequence uint64 + committed, writeStarted time.Time +} + +// Caller holds ledger then timing record. Keep the existing newest-owner and +// actual-write fences; never search past an unrelated newer same-path owner. +func movePhysicalStatusLocked(ledger *mutationCommitLedger, s *Server, record *mutationCommitRecord) *mutationCommitRecord { + physical := linkedMovePhysicalLocked(ledger, s, record) + if physical == nil { + return nil + } + firstPath := true + for i := len(ledger.ordered) - 1; i >= 0; i-- { + candidate := ledger.ordered[i] + if candidate == physical || (!candidate.isPublicationOwner() && candidate.kind != moveFileObservationCommit) || candidate.absPath != record.absPath { + continue + } + if firstPath { + firstPath = false + if candidate != record { + return nil + } + } + if candidate == record { + continue + } + candidate.mu.RLock() + competing := candidate.disk == mutationDiskCommitted && candidate.commitSeq > record.commitSeq || + candidate.disk == mutationDiskInFlight && !candidate.diskWriteStartedAt.IsZero() + candidate.mu.RUnlock() + if competing { + return nil + } } - record.disk, record.newSHA, record.bytesWritten = mutationDiskCommitted, blob, len(content) - record.commitSeq = mutationDiskCommitSequence.Add(1) - return err // observation never changes the real writer outcome + return physical +} + +func (s *Server) movePhysicalStatusChild(record *mutationCommitRecord, checkout, incarnation string, render func(*mutationCommitRecord) map[string]any) map[string]any { + ledger := &s.mutationCommits + ledger.mu.Lock() + record.mu.RLock() + physical := movePhysicalStatusLocked(ledger, s, record) + if physical == nil || !mutationCheckoutScopeMatches(checkout, incarnation, record.checkoutID, record.checkoutIncarnation) { + record.mu.RUnlock() + ledger.mu.Unlock() + return nil + } + witness := movePhysicalStatusWitness{ack: record.acknowledgement, path: record.absPath, blob: record.newSHA, + checkout: record.checkoutID, incarnation: record.checkoutIncarnation, sequence: record.commitSeq, + committed: record.committedAt, writeStarted: record.diskWriteStartedAt} + record.mu.RUnlock() + ledger.mu.Unlock() + + // This refresh may itself take ledger/record locks. No timing/ledger lock + // crosses it, and no P/B/graph fact is borrowed from the shared RPC clock. + child := render(physical) + + ledger.mu.Lock() + defer ledger.mu.Unlock() + record.mu.RLock() + defer record.mu.RUnlock() + if movePhysicalStatusLocked(ledger, s, record) != physical || record.acknowledgement != witness.ack || + record.absPath != witness.path || record.newSHA != witness.blob || record.commitSeq != witness.sequence || + record.checkoutID != witness.checkout || record.checkoutIncarnation != witness.incarnation || + !record.committedAt.Equal(witness.committed) || !record.diskWriteStartedAt.Equal(witness.writeStarted) || + !mutationCheckoutScopeMatches(checkout, incarnation, record.checkoutID, record.checkoutIncarnation) { + return nil + } + return child } func (o *moveObservation) finishWrites(success bool) { @@ -158,6 +261,7 @@ func (o *moveObservation) observer(ctx context.Context, path string, idx *indexe ledger.mu.Lock() record.mu.Lock() valid := ledger.byID[record.id] == record && record.kind == moveFileObservationCommit && record.owner == o.server && record.moveFile != nil && record.moveFile.writeVerdict == "all_writes_completed" && !record.moveFile.directClaimed && record.disk == mutationDiskCommitted && record.newSHA != "" && !record.committedAt.IsZero() && record.commitSeq != 0 + valid = valid && linkedMovePhysicalLocked(ledger, o.server, record) != nil if valid { witness.id, witness.operation, witness.path, witness.blob, witness.committed, witness.commitSeq = record.id, record.key, record.absPath, record.newSHA, record.committedAt, record.commitSeq record.moveFile.directClaimed = true @@ -186,14 +290,23 @@ func (s *Server) moveReadRepairObserver(ctx context.Context, path string, idx *i if !record.isPublicationOwner() && record.kind != moveFileObservationCommit || record.absPath != path { continue } + physical := record if record.kind != moveFileObservationCommit { - return nil, false + record.mu.RLock() + paired := record.moveTimingOwner.Value() + record.mu.RUnlock() + if paired == nil { + return nil, false + } + record = paired } record.mu.Lock() defer record.mu.Unlock() - if ledger.byID[record.id] != record || record.owner != s || record.moveFile == nil || record.moveFile.readClaimed || record.disk != mutationDiskCommitted || record.newSHA == "" || record.committedAt.IsZero() || record.commitSeq == 0 { + linked := linkedMovePhysicalLocked(ledger, s, record) + if linked == nil || physical != record && linked != physical || ledger.byID[record.id] != record || record.owner != s || record.moveFile == nil || record.moveFile.readClaimed || record.disk != mutationDiskCommitted || record.newSHA == "" || record.committedAt.IsZero() || record.commitSeq == 0 { return nil, true } + // Capture can precede the overall write verdict. Completion still requires // that exact record's final successful verdict; no later retry can rebind it. if record.moveFile.writeVerdict != "pending" && record.moveFile.writeVerdict != "all_writes_completed" { @@ -269,10 +382,19 @@ func (w moveDirectWitness) complete(observed indexer.ReadRepairObservation) { locked[i].mu.RUnlock() } }() + record.mu.RLock() + ownedPhysical := linkedMovePhysicalLocked(ledger, s, record) + record.mu.RUnlock() firstPath := true refused := "" + if ownedPhysical == nil { + refused = "unavailable_physical_owner" + } for i := len(ledger.ordered) - 1; i >= 0; i-- { candidate := ledger.ordered[i] + if candidate == ownedPhysical { + continue + } // same actual write, already checked above if !candidate.isPublicationOwner() && candidate.kind != moveFileObservationCommit || candidate.absPath != w.path { continue } @@ -378,11 +500,50 @@ func (o *moveObservation) noteDirectUnavailable(path, reason string) { } } +// Admission labels describe the route actually taken. They never manufacture +// a direct publication interval or confer physical/freshness authority. +func (o *moveObservation) observeAdmission(path string, admission *sourceMutationAdmission) { + if o == nil { + return + } + if admission.inlinePath != "" { + if admission.checkout == nil { + admission.move = o + } else { + o.noteDirectUnavailable(path, "unavailable_checkout_refresh_observer") + } + return + } + state := "not_observed" + if admission.receipt != nil || admission.immediate.Receipt != "" { + state = "admitted" + } else if admission.immediate.Err != nil { + state = "admission_failed" + } + record := o.records[path] + if record == nil { + return + } + ledger := &o.server.mutationCommits + ledger.mu.Lock() + defer ledger.mu.Unlock() + record.mu.Lock() + defer record.mu.Unlock() + if ledger.byID[record.id] != record || record.moveFile == nil { + return + } + data := record.moveFile + data.backgroundState = state + data.backgroundWait = admission.receipt != nil && !admission.policy.local() + data.directClaimed, data.readClaimed = true, true + data.directState, data.readState = "unavailable_queued_publication", "unavailable_queued_publication" +} + func (s *Server) moveFileObservationPayload(ctx context.Context, id string) (map[string]any, bool) { if !strings.HasPrefix(id, "move-file-") { return nil, false } - payload := map[string]any{"found": false, "receipt": id, "receipt_kind": "move_file_observation", "guidance": "observation only; source_id/target_id/touched and real write outcomes remain the move authority"} + payload := map[string]any{"found": false, "receipt": id, "receipt_kind": "move_file_observation", "guidance": "observation only; source_id/target_id/touched and real write outcomes remain the move authority", "physical_commit_state": "unavailable"} checkout, incarnation := mutationCheckoutScope(ctx) var ack *editAcknowledgement var direct, readAssisted *moveDirectObservation @@ -398,10 +559,21 @@ func (s *Server) moveFileObservationPayload(ctx context.Context, id string) (map payload["direct_reconcile_state"], payload["requested_background_state"] = data.directState, "not_applicable_direct_reconcile" payload["read_assisted_reconcile_state"] = data.readState payload["move_request_cause"] = map[string]string{"session": data.moveSession, "request_id_key": data.moveRequestKey} - // This handler calls the existing synchronous reconcile, never queue - // admission/wait. Preserve raw ACK nulls/counts and name applicability - // explicitly instead of manufacturing measured zero scheduler work. - payload["acknowledgement_phase_applicability"] = map[string]string{"publish": "not_applicable_direct_reconcile", "reindex_wait": "not_applicable_direct_reconcile"} + applicability := map[string]string{"publish": "not_applicable_direct_reconcile", "reindex_wait": "not_applicable_direct_reconcile"} + if data.backgroundState != "" { + payload["requested_background_state"] = data.backgroundState + applicability["publish"], applicability["reindex_wait"] = "not_observed", "not_observed" + if data.backgroundState == "admitted" || data.backgroundState == "admission_failed" { + applicability["publish"] = "applicable" + } + if data.backgroundState == "admitted" { + applicability["reindex_wait"] = "not_applicable_fast_ack" + if data.backgroundWait { + applicability["reindex_wait"] = "applicable" + } + } + } + payload["acknowledgement_phase_applicability"] = applicability timing := map[string]any{"scope": "move_file_write", "clock": "monotonic", "state": "not_observed", "outcome": record.disk} if !record.diskWriteStartedAt.IsZero() && !record.committedAt.IsZero() { timing["state"] = "invalid_order" @@ -438,6 +610,14 @@ func (s *Server) moveFileObservationPayload(ctx context.Context, id string) (map snapshot = © } payload["acknowledgement"] = buildAcknowledgementPayload(snapshot, "unavailable_ledger_entry_or_scope") + if payload["found"] == true && record != nil { + if child := s.movePhysicalStatusChild(record, checkout, incarnation, s.mutationStatusPayload); child != nil { + payload["physical_commit"] = child + payload["physical_commit_state"] = "observed" + payload["physical_commit_shared_acknowledgement"] = true + payload["physical_commit_additive_with_acknowledgement"] = false + } + } return payload, true } diff --git a/internal/mcp/move_file_observation_test.go b/internal/mcp/move_file_observation_test.go index 0b1afb76d..29e3436a3 100644 --- a/internal/mcp/move_file_observation_test.go +++ b/internal/mcp/move_file_observation_test.go @@ -12,6 +12,7 @@ import ( "sync" "testing" "time" + "weak" mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" @@ -131,8 +132,8 @@ func moveObservationExecutionFixture(t *testing.T) (*Server, *moveObservation, c t.Cleanup(ack.finishActual) observation := s.beginMoveObservation(ctx, "fixture/source.go::Before", "fixture/target.go::Before", source, target, "fixture/source.go", "fixture/target.go", true) require.NotNil(t, observation) - require.NoError(t, observation.write(ack, target, []byte("package main\nfunc Moved() {}\n"))) - require.NoError(t, observation.write(ack, source, []byte("package main\nfunc After() { Existing() }\n"))) + require.NoError(t, writeObservedMoveForTest(t, observation, ack, target, []byte("package main\nfunc Moved() {}\n"))) + require.NoError(t, writeObservedMoveForTest(t, observation, ack, source, []byte("package main\nfunc After() { Existing() }\n"))) observation.finishWrites(true) return s, observation, ctx, ack, source, target } @@ -159,38 +160,52 @@ func captureMoveActualExecution(t *testing.T, s *Server, path string, observer * } } -func TestMoveDirectRefusesOlderAdmissionWhichActuallyCommitsLaterSameBytes(t *testing.T) { +func TestMoveWaitsForOlderHeldPhysicalWrite(t *testing.T) { s, _ := setupContractMultiRepoServer(t, "fixture") t.Cleanup(func() { s.DrainBackground(); require.NoError(t, s.multiIndexer.Close(context.Background())) }) root, ok := s.multiIndexer.RepoRoot("fixture") require.True(t, ok) source, target := filepath.Join(root, "source.go"), filepath.Join(root, "target.go") bytes := []byte("package main\nfunc After() { Existing() }\n") - require.NoError(t, os.WriteFile(source, []byte("package main\nfunc Before() {}\n"), 0600)) - require.NoError(t, os.WriteFile(target, []byte("package main\nfunc OldTarget() {}\n"), 0600)) + require.NoError(t, os.WriteFile(source, []byte("package main\nfunc Before() {}\n"), 0o600)) + require.NoError(t, os.WriteFile(target, []byte("package main\nfunc OldTarget() {}\n"), 0o600)) _, err := s.multiIndexer.IncrementalReindexRepo("fixture", []string{source, target}) require.NoError(t, err) entered, release := make(chan *mutationCommitRecord, 1), make(chan struct{}) var once sync.Once unblock := func() { once.Do(func() { close(release) }) } - s.mutationPreCommitHook = func(record *mutationCommitRecord) { entered <- record; <-release } + s.mutationPreCommitHook = func(record *mutationCommitRecord) { + if record.key == "older-admission" { + entered <- record + <-release + } + } type result struct { record *mutationCommitRecord err error } done := make(chan result, 1) - joined := false go func() { - record, err := s.commitFileMutation(t.Context(), "edit_file", "older-admission", "same-bytes", "fixture/source.go", source, bytes, 0600) + record, err := s.commitHeldFileMutationForTest(t, t.Context(), "edit_file", "older-admission", "same-bytes", "fixture/source.go", source, bytes, 0o600) done <- result{record, err} }() + joined := false + var moveDone chan error + moveJoined := false t.Cleanup(func() { unblock() if !joined { select { case <-done: case <-time.After(5 * time.Second): - t.Error("competing real writer was not joined") + t.Error("older writer did not join") + } + } + if moveDone != nil && !moveJoined { + select { + case <-moveDone: + case <-time.After(5 * time.Second): + t.Error("move writer did not join") } } }) @@ -198,36 +213,66 @@ func TestMoveDirectRefusesOlderAdmissionWhichActuallyCommitsLaterSameBytes(t *te select { case older = <-entered: case <-time.After(5 * time.Second): - t.Fatal("older writer not admitted") + t.Fatal("older writer was not admitted") } ctx, ack, _ := s.withEditAcknowledgement(t.Context()) defer ack.finishActual() observation := s.beginMoveObservation(ctx, "fixture/source.go::Before", "fixture/target.go::Before", source, target, "fixture/source.go", "fixture/target.go", true) - require.NoError(t, observation.write(ack, target, []byte("package main\nfunc Moved() {}\n"))) - require.NoError(t, observation.write(ack, source, bytes)) - observation.finishWrites(true) - idx := s.multiIndexer.GetIndexer("fixture") - observer := observation.observer(ctx, source, idx) - require.NotNil(t, observer) - unblock() - var finished result + require.NoError(t, writeObservedMoveForTest(t, observation, ack, target, []byte("package main\nfunc Moved() {}\n"))) + moveDone = make(chan error, 1) + attempted := make(chan struct{}) + go func() { close(attempted); moveDone <- writeObservedMoveForTest(t, observation, ack, source, bytes) }() + <-attempted select { - case finished = <-done: - joined = true - case <-time.After(5 * time.Second): - t.Fatal("older actual writer did not return") + case err := <-moveDone: + moveJoined = true + t.Fatalf("move overtook a held source writer: %v", err) + case <-time.After(25 * time.Millisecond): } + unblock() + finished := <-done + joined = true require.NoError(t, finished.err) require.Same(t, older, finished.record) + require.NoError(t, <-moveDone) + moveJoined = true + observation.finishWrites(true) record := observation.records[source] - require.Greater(t, older.commitSeq, record.commitSeq, "guard must exercise actual commit order opposite admission order") + require.Greater(t, record.commitSeq, older.commitSeq, "the later source write must follow actual lock release") + idx := s.multiIndexer.GetIndexer("fixture") + observer := observation.observer(ctx, source, idx) + require.NotNil(t, observer) + actual := captureMoveActualExecution(t, s, source, observer) + require.True(t, actual.AcceptedFor(idx, gitBlobSHA(bytes))) + status, _ := s.moveFileObservationPayload(t.Context(), record.id) + require.Equal(t, "observed", status["direct_reconcile_state"]) +} + +func TestMoveDirectRejectsModeledLegacyReorderedCommit(t *testing.T) { + s, move, ctx, _, source, _ := moveObservationExecutionFixture(t) + record := move.records[source] + idx := s.multiIndexer.GetIndexer("fixture") + observer := move.observer(ctx, source, idx) + require.NotNil(t, observer) + // The old unguarded admission/commit inversion is now unreachable through + // source writers. Model only its ledger evidence to retain the defensive + // compatibility fence; this is not an actual-write/performance claim. + older := s.beginMutationCommit(t.Context(), "edit_file", "modeled-legacy", "same-bytes", "fixture/source.go", source) + older.mu.Lock() + older.startedAt = record.startedAt.Add(-time.Nanosecond) + older.mu.Unlock() + s.mutationCommits.mu.Lock() + ordered := s.mutationCommits.ordered + copy(ordered[1:], ordered[:len(ordered)-1]) + ordered[0] = older + s.mutationCommits.mu.Unlock() + older.markCommitted(record.newSHA, record.bytesWritten) + require.Greater(t, older.commitSeq, record.commitSeq) actual := captureMoveActualExecution(t, s, source, observer) - require.True(t, actual.AcceptedFor(idx, gitBlobSHA(bytes)), "wrong-stage rejection would not test competing commit fence") + require.True(t, actual.AcceptedFor(idx, record.newSHA)) status, _ := s.moveFileObservationPayload(t.Context(), record.id) require.Equal(t, "unavailable_competing_commit", status["direct_reconcile_state"]) require.NotContains(t, status, "direct_reconcile") - // A repeated callback must not revise the first final refusal even when - // competing evidence has since changed from committed to in-flight. older.mu.Lock() older.disk = mutationDiskInFlight older.diskWriteStartedAt = actual.AcceptedAt() @@ -235,7 +280,6 @@ func TestMoveDirectRefusesOlderAdmissionWhichActuallyCommitsLaterSameBytes(t *te observer.Complete(actual) repeated, _ := s.moveFileObservationPayload(t.Context(), record.id) require.Equal(t, "unavailable_competing_commit", repeated["direct_reconcile_state"]) - require.NotContains(t, repeated, "direct_reconcile") } func TestMoveDirectRejectsActualEarlyAcceptanceForeignOwnerBytesAndEviction(t *testing.T) { @@ -251,15 +295,15 @@ func TestMoveDirectRejectsActualEarlyAcceptanceForeignOwnerBytesAndEviction(t *t require.NotNil(t, observer) record := observation.records[source] if kind == "foreign_bytes" { - require.NoError(t, writeMoveFileObserved(nil, source, []byte("package main\nfunc OtherBytes() { Existing() }\n"))) + require.NoError(t, writeMoveFileForTest(t, s, nil, source, []byte("package main\nfunc OtherBytes() { Existing() }\n"))) } actual := captureMoveActualExecution(t, s, source, nil) if kind == "early_acceptance" { ctx2, ack2, _ := s.withEditAcknowledgement(t.Context()) defer ack2.finishActual() second := s.beginMoveObservation(ctx2, "fixture/source.go::Before", "fixture/target.go::Before", source, target, "fixture/source.go", "fixture/target.go", true) - require.NoError(t, second.write(ack2, target, []byte("package main\nfunc Moved() {}\n"))) - require.NoError(t, second.write(ack2, source, []byte("package main\nfunc After() { Existing() }\n"))) + require.NoError(t, writeObservedMoveForTest(t, second, ack2, target, []byte("package main\nfunc Moved() {}\n"))) + require.NoError(t, writeObservedMoveForTest(t, second, ack2, source, []byte("package main\nfunc After() { Existing() }\n"))) second.finishWrites(true) observation, record = second, second.records[source] observer = second.observer(ctx2, source, idx) @@ -295,10 +339,10 @@ func TestMoveFileWriteWindowsPartialFailureAndUnobservedIsolation(t *testing.T) s, _, ctx, ack, source, target := moveObservationExecutionFixture(t) observation := s.beginMoveObservation(ctx, "fixture/source.go::Before", "fixture/target.go::Before", source, target, "fixture/source.go", "fixture/target.go", true) targetBytes := []byte("package main\nfunc ActuallyWritten() {}\n") - require.NoError(t, observation.write(ack, target, targetBytes)) + require.NoError(t, writeObservedMoveForTest(t, observation, ack, target, targetBytes)) require.NoError(t, os.Remove(source)) require.NoError(t, os.Mkdir(source, 0700)) - require.Error(t, observation.write(ack, source, []byte("package main\nfunc CannotReplaceDirectory() {}\n"))) + require.Error(t, writeObservedMoveForTest(t, observation, ack, source, []byte("package main\nfunc CannotReplaceDirectory() {}\n"))) observation.finishWrites(false) targetStatus, _ := s.moveFileObservationPayload(t.Context(), observation.records[target].id) sourceStatus, _ := s.moveFileObservationPayload(t.Context(), observation.records[source].id) @@ -386,7 +430,7 @@ func TestMoveFileWriteTimingPreservesEqualClockReadings(t *testing.T) { source, target := filepath.Join(root, "source.go"), filepath.Join(root, "target.go") observation := s.beginMoveObservation(ctx, "fixture/source.go::Before", "fixture/target.go::Before", source, target, "fixture/source.go", "fixture/target.go", true) require.NotNil(t, observation) - require.NoError(t, observation.write(ack, target, []byte("package main\n"))) + require.NoError(t, writeObservedMoveForTest(t, observation, ack, target, []byte("package main\n"))) record := observation.records[target] // Model clock resolution at the report boundary after a real write. // No producer value is rounded up or replaced by a timing sleep. @@ -412,3 +456,71 @@ func TestMoveFileWriteTimingPreservesEqualClockReadings(t *testing.T) { }) } } + +// Fixture writes acquire real admission too; production callers retain their +// complete CAS set instead of reacquiring a path around individual effects. +func writeObservedMoveForTest(t *testing.T, observation *moveObservation, ack *editAcknowledgement, path string, content []byte) error { + t.Helper() + ctx := context.WithValue(t.Context(), editAcknowledgementKey{}, ack) + ctx, release, err := acquireMutationPath(ctx, path) + if err != nil { + return err + } + defer release() + _, err = observation.write(ctx, observation.server, path, content) + return err +} + +func writeMoveFileForTest(t *testing.T, s *Server, ack *editAcknowledgement, path string, content []byte) error { + t.Helper() + ctx := context.WithValue(t.Context(), editAcknowledgementKey{}, ack) + ctx, release, err := acquireMutationPath(ctx, path) + if err != nil { + return err + } + defer release() + _, err = s.writeMoveFile(ctx, "move_symbol", path, content) + return err +} + +func TestMoveTimingRequiresItsExactRetainedPhysicalEvent(t *testing.T) { + for _, kind := range []string{"owned", "different_ack", "foreign_server", "expired_physical", "reset_link", "wrong_path"} { + t.Run(kind, func(t *testing.T) { + s, move, ctx, _, _, target := moveObservationExecutionFixture(t) + timing := move.records[target] + physical := timing.moveFile.physical.Value() + require.NotNil(t, physical) + s.mutationCommits.mu.Lock() + timing.mu.Lock() + physical.mu.Lock() + switch kind { + case "different_ack": + physical.acknowledgement = &editAcknowledgement{} + case "foreign_server": + physical.owner = &Server{} + case "expired_physical": + delete(s.mutationCommits.byID, physical.id) + case "reset_link": + timing.moveFile.physical = weak.Pointer[mutationCommitRecord]{} + case "wrong_path": + physical.absPath = target + ".foreign" + } + physical.mu.Unlock() + timing.mu.Unlock() + s.mutationCommits.mu.Unlock() + idx := s.multiIndexer.GetIndexer("fixture") + direct := move.observer(ctx, target, idx) + read, _ := s.moveReadRepairObserver(ctx, target, idx) + if kind == "owned" { + require.NotNil(t, direct) + require.NotNil(t, read) + require.Equal(t, physical.commitSeq, timing.commitSeq) + require.Equal(t, physical.diskWriteStartedAt, timing.diskWriteStartedAt) + require.Equal(t, physical.committedAt, timing.committedAt) + } else { + require.Nil(t, direct) + require.Nil(t, read) + } + }) + } +} diff --git a/internal/mcp/move_physical_status_test.go b/internal/mcp/move_physical_status_test.go new file mode 100644 index 000000000..0a3108216 --- /dev/null +++ b/internal/mcp/move_physical_status_test.go @@ -0,0 +1,280 @@ +package mcp + +import ( + "context" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "go.uber.org/zap" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + "weak" + + "github.com/stretchr/testify/require" +) + +func TestMovePhysicalStatusMatchesExistingGuardedRecord(t *testing.T) { + s, move, ctx, ack, _, target := moveObservationExecutionFixture(t) + ack.finishActual() + ack.finishReply(nil, nil) + timing := move.records[target] + physical := timing.moveFile.physical.Value() + require.NotNil(t, physical) + before := s.mutationStatusPayload(physical) + status, handled := s.moveFileObservationPayload(ctx, timing.id) + require.True(t, handled) + require.Equal(t, "observed", status["physical_commit_state"]) + require.Equal(t, before, status["physical_commit"], "render the same owned physical status, including unavailable/null publication evidence") + require.Equal(t, true, status["physical_commit_shared_acknowledgement"]) + require.Equal(t, false, status["physical_commit_additive_with_acknowledgement"]) + require.Equal(t, "move_file_observation", status["receipt_kind"]) + require.NotContains(t, status, "mutation_id") + require.NotContains(t, status, "graph_status_terminal", "the timing row does not become physical authority") + child := status["physical_commit"].(map[string]any) + require.Equal(t, physical.id, child["receipt"]) + require.NotContains(t, status, "publication_phases", "physical publication is nested, never promoted to timing-row authority") + require.Equal(t, status["acknowledgement"].(acknowledgementPayload).RPCID, child["acknowledgement"].(acknowledgementPayload).RPCID, "the child shares the actual RPC ACK; it is not another additive interval") + child["graph_status"] = "fabricated-client-value" + again, _ := s.moveFileObservationPayload(ctx, timing.id) + require.NotEqual(t, "fabricated-client-value", again["physical_commit"].(map[string]any)["graph_status"]) +} + +func TestMovePhysicalStatusRejectsBrokenExactOwners(t *testing.T) { + for _, mode := range []string{"ack", "server", "path", "blob", "sequence", "checkout", "incarnation", "evicted", "replaced", "forward-link", "reverse-link", "legacy-in-flight"} { + t.Run(mode, func(t *testing.T) { + s, move, ctx, _, _, target := moveObservationExecutionFixture(t) + timing := move.records[target] + physical := timing.moveFile.physical.Value() + require.NotNil(t, physical) + ledger := &s.mutationCommits + ledger.mu.Lock() + timing.mu.Lock() + physical.mu.Lock() + switch mode { + case "ack": + physical.acknowledgement = &editAcknowledgement{} + case "server": + physical.owner = &Server{} + case "path": + physical.absPath = target + ".foreign" + case "blob": + physical.newSHA = "foreign" + case "sequence": + physical.commitSeq++ + case "checkout": + physical.checkoutID = "foreign" + case "incarnation": + physical.checkoutIncarnation = "foreign" + case "evicted": + delete(ledger.byID, physical.id) + case "replaced": + ledger.byID[physical.id] = &mutationCommitRecord{id: physical.id} + case "forward-link": + timing.moveFile.physical = weak.Pointer[mutationCommitRecord]{} + case "reverse-link": + physical.moveTimingOwner = weak.Pointer[mutationCommitRecord]{} + case "legacy-in-flight": + // Model the legacy schedule explicitly. Real held-path admission + // now prevents this old admitted writer from overtaking a move. + legacy := &mutationCommitRecord{owner: s, id: "legacy-in-flight", absPath: target, disk: mutationDiskInFlight, + startedAt: timing.startedAt.Add(-time.Second), diskWriteStartedAt: timing.committedAt.Add(-time.Nanosecond)} + ledger.byID[legacy.id] = legacy + ledger.ordered = append([]*mutationCommitRecord{legacy}, ledger.ordered...) + } + physical.mu.Unlock() + timing.mu.Unlock() + ledger.mu.Unlock() + status, _ := s.moveFileObservationPayload(ctx, timing.id) + require.Equal(t, true, status["found"]) + require.Equal(t, "unavailable", status["physical_commit_state"]) + require.NotContains(t, status, "physical_commit") + }) + } +} + +func TestMovePhysicalStatusDoesNotSearchPastAnotherWriter(t *testing.T) { + for _, noWrite := range []bool{false, true} { + t.Run(map[bool]string{false: "same-bytes-new-ack", true: "cancelled-no-write"}[noWrite], func(t *testing.T) { + s, move, ctx, _, _, target := moveObservationExecutionFixture(t) + original, err := os.ReadFile(target) + require.NoError(t, err) + writeCtx, ack, _ := s.withEditAcknowledgement(t.Context()) + defer ack.finishActual() + writeCtx, cancel := context.WithCancel(writeCtx) + defer cancel() + writeCtx, unlock, err := acquireMutationPath(writeCtx, target) + require.NoError(t, err) + defer unlock() + if noWrite { + cancel() + } + later, err := s.commitFileMutation(writeCtx, "write_file", "new-owner", "", "target.go", target, original, 0600) + if noWrite { + require.ErrorIs(t, err, errMutationNotApplied) + } else { + require.NoError(t, err) + } + require.NotNil(t, later) + status, _ := s.moveFileObservationPayload(ctx, move.records[target].id) + require.Equal(t, "unavailable", status["physical_commit_state"]) + require.NotContains(t, status, "physical_commit") + after, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, original, after) + }) + } +} + +func TestMovePhysicalStatusRevalidatesAfterUnlockedRendering(t *testing.T) { + for _, mode := range []string{"evict", "relink", "same-scalars-new-owner"} { + t.Run(mode, func(t *testing.T) { + s, move, _, _, _, target := moveObservationExecutionFixture(t) + timing := move.records[target] + child := s.movePhysicalStatusChild(timing, "", "", func(physical *mutationCommitRecord) map[string]any { + actual := s.mutationStatusPayload(physical) // proves ledger/timing locks were released + ledger := &s.mutationCommits + ledger.mu.Lock() + defer ledger.mu.Unlock() + timing.mu.Lock() + defer timing.mu.Unlock() + switch mode { + case "evict": + delete(ledger.byID, physical.id) + case "relink": + timing.moveFile.physical = weak.Pointer[mutationCommitRecord]{} + case "same-scalars-new-owner": + timing.acknowledgement = &editAcknowledgement{} + } + return actual + }) + require.Nil(t, child, "an earlier valid scalar snapshot cannot survive lost membership or ownership") + }) + } +} + +type movePhysicalStatusExtractor struct { + parser.Extractor + active atomic.Bool + entered chan struct{} + release chan struct{} +} + +func (e *movePhysicalStatusExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult, error) { + if e.active.Load() { + select { + case e.entered <- struct{}{}: + default: + } + <-e.release + } + return e.Extractor.Extract(path, raw) +} + +func TestMovePhysicalStatusTracksRealQueuedPointPublicationWithoutReparse(t *testing.T) { + extractor := &movePhysicalStatusExtractor{Extractor: languages.NewGoExtractor(), entered: make(chan struct{}, 16), release: make(chan struct{})} + registry := parser.NewRegistry() + registry.Register(extractor) + s, root := setupTestServerWithRegistry(t, registry) + s.editAckMode = config.EditAckLocal + source, target := filepath.Join(root, "source.go"), filepath.Join(root, "target.go") + require.NoError(t, os.WriteFile(source, []byte("package main\nfunc Before() {}\n"), 0600)) + require.NoError(t, os.WriteFile(target, []byte("package main\nfunc OldTarget() {}\n"), 0600)) + _, err := s.indexer.IncrementalReindexPaths(root, []string{source, target}) + require.NoError(t, err) + watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 5}, zap.NewNop()) + require.NoError(t, err) + // Start and stop the real native frontend, but watch an idle owned + // directory so filesystem events cannot race the explicit point-ticket + // admission this control measures. The real indexer still owns root. + require.NoError(t, watcher.Start([]string{t.TempDir()})) + var release sync.Once + t.Cleanup(func() { + release.Do(func() { close(extractor.release) }) + stopErr := watcher.Stop() + s.DrainBackground() + s.indexer.Close() + require.NoError(t, stopErr) + }) + s.SetWatcher(watcher) + ctx, ack, _ := s.withEditAcknowledgement(canonicalTimingContext()) + ctx, unlock, err := acquireMutationPaths(ctx, []string{source, target}) + require.NoError(t, err) + defer unlock() + move := s.beginMoveObservation(ctx, "source.go::Before", "target.go::Before", source, target, "source.go", "target.go", true) + require.NotNil(t, move) + physical := make(map[string]*mutationCommitRecord) + physical[target], err = move.write(ctx, s, target, []byte("package main\nfunc Moved() {}\n")) + require.NoError(t, err) + physical[source], err = move.write(ctx, s, source, []byte("package main\nfunc After() {}\n")) + require.NoError(t, err) + move.finishWrites(true) + extractor.active.Store(true) // only the REAL background indexer is held + var admissions []sourceMutationAdmission + for _, path := range []string{source, target} { + admission := s.admitSourceMutations(ctx, path) + require.NotNil(t, admission.receipt) + move.observeAdmission(path, &admission) + admissions = append(admissions, admission) + } + unlock() + outcomes := s.awaitSourceMutationFrontier(ctx, admissions) + for i, path := range []string{source, target} { + require.True(t, outcomes[i].Pending) + physical[path].recordGraph(outcomes[i]) + } + ack.finishActual() + ack.finishReply(nil, nil) + select { + case <-extractor.entered: + case <-time.After(3 * time.Second): + t.Fatal("real watcher did not enter extraction") + } + for _, path := range []string{source, target} { + status, _ := s.moveFileObservationPayload(ctx, move.records[path].id) + require.Equal(t, "observed", status["physical_commit_state"]) + require.Equal(t, "admitted", status["requested_background_state"]) + require.Equal(t, "unavailable_queued_publication", status["direct_reconcile_state"]) + child := status["physical_commit"].(map[string]any) + require.Equal(t, mutationGraphPending, child["graph_status"]) + require.Equal(t, false, child["graph_status_terminal"]) + require.Equal(t, s.mutationStatusPayload(physical[path]), child) + require.NotContains(t, status, "graph_status_terminal") + } + release.Do(func() { close(extractor.release) }) + for _, path := range []string{source, target} { + require.Eventually(t, func() bool { + status, _ := s.moveFileObservationPayload(ctx, move.records[path].id) + child, ok := status["physical_commit"].(map[string]any) + return ok && child["graph_status"] == mutationGraphFresh && child["graph_status_terminal"] == true + }, 5*time.Second, time.Millisecond) + status, _ := s.moveFileObservationPayload(ctx, move.records[path].id) + child := status["physical_commit"].(map[string]any) + require.Equal(t, s.mutationStatusPayload(physical[path]), child) + phases, ok := child["publication_phases"].(indexer.PublicationPhaseSnapshot) + require.True(t, ok) + require.True(t, phases.Terminal) + require.Equal(t, indexer.PublicationSourceMCPEdit, phases.Source) + } + parses := parser.ParseCount() + beforeSource, err := os.ReadFile(source) + require.NoError(t, err) + beforeTarget, err := os.ReadFile(target) + require.NoError(t, err) + for range 3 { + for _, path := range []string{source, target} { + s.moveFileObservationPayload(ctx, move.records[path].id) + } + } + require.Equal(t, parses, parser.ParseCount(), "status cannot trigger a second parse/reconcile") + afterSource, err := os.ReadFile(source) + require.NoError(t, err) + afterTarget, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, beforeSource, afterSource) + require.Equal(t, beforeTarget, afterTarget) +} diff --git a/internal/mcp/move_read_assisted_observation_test.go b/internal/mcp/move_read_assisted_observation_test.go index 3a7dd9491..7ebbba2e9 100644 --- a/internal/mcp/move_read_assisted_observation_test.go +++ b/internal/mcp/move_read_assisted_observation_test.go @@ -52,7 +52,7 @@ func TestMoveReadAssistedRejectsNewerPublicationOwners(t *testing.T) { // owner has the actual later commit order for each supported owner kind. bytes, err := os.ReadFile(target) require.NoError(t, err) - require.NoError(t, writeMoveFileObserved(nil, target, bytes)) + require.NoError(t, writeMoveFileForTest(t, s, nil, target, bytes)) foreign := &mutationCommitRecord{kind: kind, owner: s, id: "foreign-newer", absPath: target, disk: mutationDiskCommitted, newSHA: gitBlobSHA(bytes), committedAt: time.Now(), commitSeq: mutationDiskCommitSequence.Add(1)} s.mutationCommits.put(foreign) actual := captureMoveActualExecution(t, s, target, observer) diff --git a/internal/mcp/mutation_commit.go b/internal/mcp/mutation_commit.go index ebea6fb81..05a388af4 100644 --- a/internal/mcp/mutation_commit.go +++ b/internal/mcp/mutation_commit.go @@ -15,8 +15,11 @@ import ( "time" "weak" + "github.com/zeebo/blake3" "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" + "github.com/zzet/gortex/internal/platform" ) // The commit ledger exists because a tool call has two independent terminal @@ -86,7 +89,7 @@ const mutationIDParamDescription = "Optional idempotency key. Retrying with the "replays the first result instead of writing twice — the safe way to retry an abandoned call. " + "A different edit under the same key is refused." -var mutationCommitSequence atomic.Uint64 +var mutationCommitSequence = epochMutationSequence() // mutationDiskCommitSequence orders disk commits. It is stamped after the // rename and before the commit's refresh ticket is admitted, so a fresh @@ -117,6 +120,7 @@ type mutationCommitRecord struct { pendingSourceRecovered bool id string + ackMode string tool string key string relPath string @@ -138,8 +142,10 @@ type mutationCommitRecord struct { // terminality is the opposite in the two cases. graphRecorded bool + contentSHA256 string newSHA string bytesWritten int + publicationReceipt string reindexReceipt string reindexGeneration uint64 appliedGeneration uint64 @@ -167,14 +173,22 @@ type mutationCommitRecord struct { batchOwner [32]byte batchAfterSHA256 [32]byte moveFile *moveFileObservation + moveTimingOwner weak.Pointer[mutationCommitRecord] + fileProjection weak.Pointer[projectionEntry] + fileReason string + readbackExact bool // response is the successful tool payload, retained only for records that // carry a caller-chosen mutation_id. Without a key there is nothing to // replay against, so ordinary edits pay no memory for this. response map[string]any + // Keyed retries may arrive after path release but before the first reply. + // This bounded record owns their completion signal, never another ledger. + responseReady chan struct{} } type mutationCommitSnapshot struct { + AckMode string `json:"ack_mode,omitempty"` Receipt string `json:"receipt"` Tool string `json:"tool,omitempty"` MutationID string `json:"mutation_id,omitempty"` @@ -187,6 +201,7 @@ type mutationCommitSnapshot struct { // snapshot the flag is derived from, so the two cannot disagree. GraphRecorded bool `json:"-"` CommitSeq uint64 `json:"-"` + ContentSHA256 string `json:"content_sha256,omitempty"` NewSHA string `json:"new_sha,omitempty"` BytesWritten int `json:"bytes_written,omitempty"` Error string `json:"error,omitempty"` @@ -198,6 +213,7 @@ type mutationCommitSnapshot struct { AppliedGeneration uint64 `json:"applied_generation,omitempty"` CheckoutID string `json:"checkout_id,omitempty"` CheckoutIncarnation string `json:"checkout_incarnation,omitempty"` + publicationReceipt string readRepair *mutationReadRepairObservation } @@ -208,6 +224,7 @@ func (r *mutationCommitRecord) snapshot() mutationCommitSnapshot { r.mu.RLock() defer r.mu.RUnlock() snap := mutationCommitSnapshot{ + AckMode: r.ackMode, Receipt: r.id, Tool: r.tool, MutationID: r.key, @@ -216,11 +233,13 @@ func (r *mutationCommitRecord) snapshot() mutationCommitSnapshot { GraphStatus: r.graph, GraphRecorded: r.graphRecorded, CommitSeq: r.commitSeq, + ContentSHA256: r.contentSHA256, NewSHA: r.newSHA, BytesWritten: r.bytesWritten, Error: r.errText, Reason: r.graphReason, ReindexReceipt: r.reindexReceipt, + publicationReceipt: r.publicationReceipt, ReindexGeneration: r.reindexGeneration, AppliedGeneration: r.appliedGeneration, CheckoutID: r.checkoutID, @@ -287,6 +306,10 @@ func (r *mutationCommitRecord) markFailed(err error) { } func (r *mutationCommitRecord) markCommitted(newSHA string, bytesWritten int) { + r.markCommittedContent(newSHA, bytesWritten, "") +} + +func (r *mutationCommitRecord) markCommittedContent(newSHA string, bytesWritten int, contentSHA256 string) { if !r.isPhysicalCommit() { return } @@ -296,6 +319,7 @@ func (r *mutationCommitRecord) markCommitted(newSHA string, bytesWritten int) { if r.commitSeq == 0 { r.commitSeq = mutationDiskCommitSequence.Add(1) } + r.contentSHA256 = contentSHA256 r.newSHA = newSHA r.bytesWritten = bytesWritten r.committedAt = time.Now() @@ -370,6 +394,14 @@ func (r *mutationCommitRecord) recordGraphForReceipt(expectedReceipt string, out ledger.byReindexReceipt[outcome.Receipt] = r } } + r.publicationReceipt = "" + if r.owner != nil && outcome.publicationReceipt != "" { + if value, found := r.owner.mutationReceipts.Load(outcome.publicationReceipt); found { + if receipt, ok := value.(*mutationReceipt); ok && receipt.path == r.absPath && receipt.generation == outcome.Generation && receipt.checkoutID == r.checkoutID && receipt.checkoutIncarnation == r.checkoutIncarnation { + r.publicationReceipt = outcome.publicationReceipt + } + } + } r.reindexReceipt = outcome.Receipt r.reindexGeneration = outcome.Generation r.appliedGeneration = outcome.AppliedGeneration @@ -427,9 +459,13 @@ func (r *mutationCommitRecord) retainResponse(resp map[string]any) { } clone := make(map[string]any, len(resp)+1) for key, value := range resp { + if key == "file_view" { + continue + } // the bounded registry alone retains delta strings clone[key] = value } r.response = clone + r.closeResponseReadyLocked() } func (r *mutationCommitRecord) replayResponse() (map[string]any, bool) { @@ -530,6 +566,7 @@ func (l *mutationCommitLedger) evictLocked(now time.Time) { record.mu.RUnlock() if expired { record.mu.Lock() + record.closeResponseReadyLocked() record.publication = nil record.acknowledgement = nil record.readRepair = nil @@ -550,6 +587,7 @@ func (l *mutationCommitLedger) evictLocked(now time.Time) { l.ordered = l.ordered[1:] oldest.mu.Lock() l.unbindFreshnessLocked(oldest) + oldest.closeResponseReadyLocked() oldest.publication = nil oldest.acknowledgement = nil oldest.readRepair = nil @@ -700,6 +738,7 @@ func (n *mutationCommitNote) verdict() mutationCommitVerdict { func (s *Server) beginMutationCommit(ctx context.Context, tool, mutationID, fingerprint, relPath, absPath string) *mutationCommitRecord { record := &mutationCommitRecord{ owner: s, + ackMode: string(s.sourceAckPolicy(ctx).mode), id: fmt.Sprintf("commit-%d", mutationCommitSequence.Add(1)), tool: tool, key: mutationID, @@ -713,6 +752,9 @@ func (s *Server) beginMutationCommit(ctx context.Context, tool, mutationID, fing startedAt: time.Now(), acknowledgement: editAcknowledgementFrom(ctx, s), } + if mutationID != "" { + record.responseReady = make(chan struct{}) + } if state := checkoutMutationFromContext(ctx); state != nil { record.checkoutID = state.checkoutID record.checkoutIncarnation = state.incarnation @@ -723,6 +765,28 @@ func (s *Server) beginMutationCommit(ctx context.Context, tool, mutationID, fing return record } +type sourceMutationOperationKind uint8 + +const sourceWrite sourceMutationOperationKind = 0 + +const ( + batchForwardWrite sourceMutationOperationKind = iota + 1 + batchForwardRemove + batchRestoreWrite + batchRestoreRemove + sourceRemove + sourceCohortWrite +) + +// This private descriptor selects ordinary prepared source I/O or a durable +// batch's existing terminal authority. Journal/backup metadata never enters +// this boundary. Batch observation belongs exclusively to forward I/O. +type sourceMutationOperation struct { + kind sourceMutationOperationKind + observation *mutationCommitRecord + projection *preparedFileProjection +} + // commitFileMutation is the single guarded disk-commit primitive for the // single-file mutating tools. Every caller gets three things it did not have // before: a refusal when the request is already dead, a durable receipt, and a @@ -732,7 +796,49 @@ func (s *Server) commitFileMutation( tool, mutationID, fingerprint, relPath, absPath string, data []byte, perm os.FileMode, + operations ...sourceMutationOperation, ) (*mutationCommitRecord, error) { + remove := len(operations) == 1 && operations[0].kind == sourceRemove + ordinarySource := len(operations) == 0 || len(operations) == 1 && (remove || operations[0].kind == sourceWrite || operations[0].kind == sourceCohortWrite) + if !ordinarySource { + // Branch before beginMutationCommit: it registers ordinary source + // authority even before markCommitted. The durable transaction is the + // sole authority for forward effects and restoration alike. + if len(operations) != 1 { + return nil, fmt.Errorf("%w: invalid source operation", errMutationNotApplied) + } + operation := operations[0] + if !mutationPathAdmitted(ctx, pathkey.MutationKey(absPath)) { + return nil, fmt.Errorf("%w: batch source path is not held for mutation", errMutationNotApplied) + } + if err := guardCheckoutMutationPath(ctx, absPath); err != nil { + return nil, err + } + // No cancellation or per-file Prepare here: the coordinator has crossed + // its existing commit boundary and must reach a durable terminal state. + ops := s.batchDurability() + switch operation.kind { + case batchForwardWrite: + writer := ops.writeFile + if s.batchWriteOverride != nil { + writer = s.batchWriteOverride + } + writer = s.observedBatchFileWriter(operation.observation, writer) + return nil, s.writeAcknowledgedBatchFile(ctx, writer, absPath, data, perm) + case batchForwardRemove: + remover := ops.removeFile + if s.batchRemoveOverride != nil { + remover = s.batchRemoveOverride + } + return nil, s.removeAcknowledgedBatchFile(ctx, remover, absPath) + case batchRestoreWrite: + return nil, ops.writeFile(absPath, data, perm) + case batchRestoreRemove: + return nil, ops.removeFile(absPath) + default: + return nil, fmt.Errorf("%w: invalid batch source operation", errMutationNotApplied) + } + } record := s.beginMutationCommit(ctx, tool, mutationID, fingerprint, relPath, absPath) if s.mutationPreCommitHook != nil { s.mutationPreCommitHook(record) @@ -745,14 +851,21 @@ func (s *Server) commitFileMutation( record.markNotApplied(err) return record, fmt.Errorf("%w: %w", errMutationNotApplied, err) } + if !mutationPathAdmitted(ctx, pathkey.MutationKey(absPath)) { + err := fmt.Errorf("%w: source path is not held for mutation", errMutationNotApplied) + record.markNotApplied(err) + return record, err + } // UTF-16 sources refuse every write (#846): the indexer decodes them for // extraction, so their symbols are discoverable and every tool that // writes from graph positions — present or future — funnels here. // Splicing UTF-8 into NUL-interleaved bytes corrupts the file; the // refusal names the encoding and the re-encode escape hatch. - if err := guardUTF16SourceWrite(tool, relPath, absPath); err != nil { - record.markNotApplied(err) - return record, err + if !remove { + if err := guardUTF16SourceWrite(tool, relPath, absPath); err != nil { + record.markNotApplied(err) + return record, err + } } if err := prepareCheckoutMutation(ctx, absPath); err != nil { record.markNotApplied(err) @@ -772,23 +885,100 @@ func (s *Server) commitFileMutation( record.markNotApplied(err) return record, fmt.Errorf("%w: %w", errMutationNotApplied, err) } + var prepared *preparedFileProjection + var baseline *plannedSource + if remove || len(operations) == 0 || operations[0].projection == nil { + baseline = captureFileViewBaseline(ctx, absPath) + } + if remove { + prepared = s.buildRemovalProjection(ctx, relPath, absPath) + } else { + if len(operations) == 1 { + prepared = operations[0].projection + } + if prepared == nil { + if s.indexer == nil && s.multiIndexer == nil { + // There is no parser call to observe. Keep the explicit + // projection refusal without fabricating a parse-gate lap. + prepared, _ = s.buildFileProjection(ctx, absPath, data) + } else { + s.noteAcknowledgementParser(ctx, "source_projection") + lap := s.beginAcknowledgementLap(ctx, editAckParseGate) + prepared, _ = s.buildFileProjection(ctx, absPath, data) + lap.finish() + } + } + if prepared.key != pathkey.MutationKey(absPath) || prepared.rawHash != blake3.Sum256(data) { + err := fmt.Errorf("%w: prepared source identity changed", errMutationNotApplied) + record.markNotApplied(err) + return record, err + } + } + if prepared.entry != nil && prepared.entry.fileView == nil { + s.prepareFileViewEvidence(ctx, prepared, baseline, data) + } + // A bounded projection may have waited. Recheck cancellation and the live + // captured path proof before the first byte effect, never after writing. + if err := ctx.Err(); err != nil { + record.markNotApplied(err) + return record, fmt.Errorf("%w: %w", errMutationNotApplied, err) + } + if !mutationPathAdmitted(ctx, pathkey.MutationKey(absPath)) { + err := fmt.Errorf("%w: source identity changed during preparation", errMutationNotApplied) + record.markNotApplied(err) + return record, err + } + if err := guardCheckoutMutationPath(ctx, absPath); err != nil { + record.markNotApplied(err) + return record, err + } record.mu.Lock() record.diskWriteStartedAt = time.Now() record.mu.Unlock() indexer.StampPublicationPhase(ctx, indexer.PublicationDiskWriteStarted) diskLap := s.beginAcknowledgementLap(ctx, editAckDiskWrite) - writeErr := agents.AtomicWriteFile(absPath, data, perm) + var writeErr error + if remove { + writeErr = platform.RemoveFile(absPath) + if errors.Is(writeErr, os.ErrNotExist) { + writeErr = nil + } + } else { + writeErr = agents.AtomicWriteFile(absPath, data, perm) + } diskLap.finish() // actual call end, before committed-hash/receipt bookkeeping if err := writeErr; err != nil { record.markFailed(err) return record, err } + contentSHA256 := "" + if !remove { + // One actual raw-byte SHA256, shared by physical receipt and + // checkout publication identity. The graph's BLAKE3 is separate. + contentSHA256 = prepared.contentSHA256 + if contentSHA256 == "" { + // Compatibility for explicitly modeled preparations: production + // preparations already hash their actual candidate bytes once. + sum := sha256.Sum256(data) + contentSHA256 = hex.EncodeToString(sum[:]) + } + } if state := checkoutMutationFromContext(ctx); state != nil { - sum := sha256.Sum256(data) - state.committedHash = hex.EncodeToString(sum[:]) + state.committedHash = contentSHA256 state.committedPath = absPath } - record.markCommitted(gitBlobSHA(data), len(data)) + if remove { + record.markCommitted("", 0) + } else { + record.markCommittedContent(gitBlobSHA(data), len(data), contentSHA256) + } + if len(operations) == 1 && operations[0].kind == sourceCohortWrite { + record.mu.Lock() + record.fileReason = "frontier_not_published" + record.mu.Unlock() + } else { + s.publishCommittedFileProjection(ctx, record, prepared) + } return record, nil } @@ -834,8 +1024,10 @@ func attachMutationCommit(resp map[string]any, record *mutationCommitRecord) { if !record.isPhysicalCommit() { return } + resp["ack_mode"] = record.ackMode resp["mutation_receipt"] = record.id resp["disk_status"] = record.diskStatus() + record.attachFileProjection(resp) if record.key != "" { resp["mutation_id"] = record.key } @@ -886,7 +1078,35 @@ func (s *Server) replayMutation(mutationID, fingerprint string) (map[string]any, "mutation_id %q already committed but its response was not retained; query mutation_status for the receipt: %s", mutationID, encoded), true } + return s.renderMutationReplay(record, resp) +} + +func (s *Server) renderMutationReplay(record *mutationCommitRecord, resp map[string]any) (map[string]any, string, bool) { + // The disk verdict and receipt remain historical. File-view facts are + // live observations and cannot be replayed after another commit replaced + // the registry entry. Refresh only those scalars on this response clone. + delete(resp, "file_reason") + s.refreshPendingSourceRecord(record) + snap := record.snapshot() + id := snap.ReindexReceipt + if id == "" { + id = snap.publicationReceipt + } + if outcome, found := s.mutationReceiptState(id); found { + if snap.ReindexReceipt == "" { + outcome.Receipt = "" + } + attachMutationFreshnessFields(resp, outcome) + } else { + attachMutationTierState(resp, indexer.MutationTierState{}) + delete(resp, "reindex_pending") + delete(resp, "syntax_health") + resp["graph_status"] = snap.GraphStatus + resp["reindexed"] = snap.GraphStatus == mutationGraphFresh + } + record.attachFileProjection(resp) resp["replayed"] = true + resp["ack_mode"] = record.ackMode resp["mutation_receipt"] = record.id return resp, "", true } diff --git a/internal/mcp/mutation_commit_lock_test.go b/internal/mcp/mutation_commit_lock_test.go new file mode 100644 index 000000000..648f99e84 --- /dev/null +++ b/internal/mcp/mutation_commit_lock_test.go @@ -0,0 +1,115 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// The commit boundary must prove ownership of this path, rather than relying +// on every current and future caller to remember a separate lock convention. +func TestCommitFileMutationRefusesUnheldSourcePath(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + _, err = srv.commitFileMutation(t.Context(), "edit_file", "unheld", "unheld", "main.go", path, []byte("package main\n"), 0o644) + require.Error(t, err, "an unheld source path must refuse before disk mutation") + after, readErr := os.ReadFile(path) + require.NoError(t, readErr) + require.Equal(t, before, after, "a refused unheld commit must preserve exact bytes") +} + +func TestMutationCommitRequiresLiveCapturedPathAdmission(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + t.Cleanup(release) + const accepted = "package main\nfunc Accepted() {}\n" + _, err = srv.commitFileMutation(ctx, "edit_file", "held", "held", "main.go", path, []byte(accepted), 0o644) + require.NoError(t, err) + current, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, accepted, string(current)) + + // A context saved by a returned handler is not a permanent capability. + release() + _, err = srv.commitFileMutation(ctx, "edit_file", "released", "released", "main.go", path, []byte("package main\n"), 0o644) + require.ErrorIs(t, err, errMutationNotApplied) + current, err = os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, accepted, string(current)) + + otherCtx, otherRelease, err := acquireMutationPath(t.Context(), filepath.Join(dir, "other.go")) + require.NoError(t, err) + defer otherRelease() + _, err = srv.commitFileMutation(otherCtx, "edit_file", "wrong-path", "wrong-path", "main.go", path, []byte("package main\n"), 0o644) + require.ErrorIs(t, err, errMutationNotApplied) +} + +func TestMutationCommitInheritedAdmissionHonorsParentLifetime(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + parent, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + t.Cleanup(release) + // A nested helper can reuse its caller's set without reacquiring the same + // non-reentrant token. Releasing the child never releases the parent. + child, childRelease, err := acquireMutationPaths(parent, []string{path, path}) + require.NoError(t, err) + childRelease() + _, err = srv.commitFileMutation(child, "edit_file", "parent-held", "parent-held", "main.go", path, []byte("package main\nfunc Nested() {}\n"), 0o644) + require.NoError(t, err) + release() + _, err = srv.commitFileMutation(child, "edit_file", "parent-returned", "parent-returned", "main.go", path, []byte("package main\n"), 0o644) + require.ErrorIs(t, err, errMutationNotApplied) +} + +func TestMutationCommitRefusesRetargetedParentAlias(t *testing.T) { + srv, dir := setupTestServer(t) + originalDir, otherDir, aliasDir := filepath.Join(dir, "original"), filepath.Join(dir, "other"), filepath.Join(dir, "alias") + require.NoError(t, os.MkdirAll(originalDir, 0o755)) + require.NoError(t, os.MkdirAll(otherDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(originalDir, "file.go"), []byte("package main\nfunc Original() {}\n"), 0o644)) + other := filepath.Join(otherDir, "file.go") + const untouched = "package main\nfunc Other() {}\n" + require.NoError(t, os.WriteFile(other, []byte(untouched), 0o644)) + if err := os.Symlink(originalDir, aliasDir); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + aliasPath := filepath.Join(aliasDir, "file.go") + ctx, release, err := acquireMutationPath(t.Context(), aliasPath) + require.NoError(t, err) + defer release() + require.NoError(t, os.Remove(aliasDir)) + require.NoError(t, os.Symlink(otherDir, aliasDir)) + _, err = srv.commitFileMutation(ctx, "edit_file", "retargeted", "retargeted", "alias/file.go", aliasPath, []byte("package main\n"), 0o644) + require.ErrorIs(t, err, errMutationNotApplied) + info, err := os.Lstat(aliasDir) + require.NoError(t, err) + require.NotZero(t, info.Mode()&os.ModeSymlink) + current, err := os.ReadFile(other) + require.NoError(t, err) + require.Equal(t, untouched, string(current)) +} + +func TestMutationCommitCanceledAdmissionWritesNothing(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + parent, cancel := context.WithCancel(t.Context()) + ctx, release, err := acquireMutationPath(parent, path) + require.NoError(t, err) + defer release() + cancel() + _, err = srv.commitFileMutation(ctx, "edit_file", "canceled-held", "canceled-held", "main.go", path, []byte("package main\n"), 0o644) + require.ErrorIs(t, err, context.Canceled) + current, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, current) +} diff --git a/internal/mcp/mutation_commit_test.go b/internal/mcp/mutation_commit_test.go index 4d4786e07..a5c9ae9d1 100644 --- a/internal/mcp/mutation_commit_test.go +++ b/internal/mcp/mutation_commit_test.go @@ -49,6 +49,21 @@ func editFileRequest(args map[string]any) mcp.CallToolRequest { return req } +// commitHeldFileMutationForTest supplies the real path lock now required by +// the primitive. Keep the original request context (including cancellation and +// checkout refusal evidence) so these existing deeper-boundary tests still +// exercise their intended guard rather than a lock-admission refusal. +func (s *Server) commitHeldFileMutationForTest(t *testing.T, ctx context.Context, + tool, mutationID, fingerprint, relPath, absPath string, data []byte, perm os.FileMode, +) (*mutationCommitRecord, error) { + t.Helper() + held, release, err := acquireMutationPath(context.Background(), absPath) + require.NoError(t, err) + defer release() + ctx = context.WithValue(ctx, mutationPathAdmissionKey{}, held.Value(mutationPathAdmissionKey{})) + return s.commitFileMutation(ctx, tool, mutationID, fingerprint, relPath, absPath, data, perm) +} + // --- Criterion: timeout BEFORE the write applies nothing ------------------- // A cancelled request must not reach the disk. The handler takes its @@ -155,7 +170,7 @@ func TestAbandonedCallReportsCommittedDiskWrite(t *testing.T) { release := make(chan struct{}) defer close(release) handler := func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - record, err := s.commitFileMutation(ctx, "write_file", "", "fp", "landed.go", target, + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "fp", "landed.go", target, []byte("package p\n"), 0o644) require.NoError(t, err) require.Equal(t, mutationDiskCommitted, record.diskStatus()) @@ -208,7 +223,7 @@ func TestAbandonedCallReportsNotAppliedWrite(t *testing.T) { handler := func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { <-ctx.Done() // the deadline fires first - _, err := s.commitFileMutation(ctx, "write_file", "", "fp", "refused.go", target, + _, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "fp", "refused.go", target, []byte("package p\n"), 0o644) require.Error(t, err) return mcp.NewToolResultText("unreachable"), nil @@ -312,15 +327,14 @@ func TestMutationStatusResolvesReceiptPathAndListing(t *testing.T) { require.Equal(t, "unrecorded", unknown["disk_status"]) } -func TestMutationStatusUnknownReceiptIsAnError(t *testing.T) { +func TestMutationStatusUnknownReceiptIsLost(t *testing.T) { s := newMutationServer() req := mcp.CallToolRequest{} req.Params.Name = "mutation_status" req.Params.Arguments = map[string]any{"receipt": "commit-does-not-exist"} res, err := s.handleMutationStatus(context.Background(), req) require.NoError(t, err) - require.True(t, res.IsError) - require.Contains(t, resultText(res), "no mutation receipt") + require.Equal(t, "lost", resultJSON(t, res)["state"]) } // --- Criterion: retrying after a timeout is idempotent --------------------- @@ -719,7 +733,14 @@ func TestRenameStopsWritingOnceCancelled(t *testing.T) { } } - results := s.commitRenameWrites(ctx, writes) + paths := make([]string, len(writes)) + for i, w := range writes { + paths[i] = w.AbsPath + } + heldCtx, releasePaths, err := acquireMutationPaths(ctx, paths) + require.NoError(t, err) + defer releasePaths() + results := s.commitRenameWrites(heldCtx, writes, releasePaths) require.Len(t, results, 2, "the rename must stop instead of attempting every remaining file") require.Equal(t, "applied", results[0]["status"]) require.Equal(t, mutationDiskCommitted, results[0]["disk_status"]) diff --git a/internal/mcp/mutation_freshness_hints.go b/internal/mcp/mutation_freshness_hints.go index d593810e7..a15763a8e 100644 --- a/internal/mcp/mutation_freshness_hints.go +++ b/internal/mcp/mutation_freshness_hints.go @@ -25,7 +25,6 @@ func mutationFreshnessRecoveryHint() string { reserved.operatorReconcile, reserved.automaticReconcile, reserved.retryExhausted, - "receipt retention lapsing as a last resort", } available := hints[:0] for _, hint := range hints { diff --git a/internal/mcp/mutation_path_key_test.go b/internal/mcp/mutation_path_key_test.go new file mode 100644 index 000000000..7c476cab2 --- /dev/null +++ b/internal/mcp/mutation_path_key_test.go @@ -0,0 +1,92 @@ +package mcp + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/zzet/gortex/internal/pathkey" +) + +// Two spellings of one file must share one mutation lock, or two edits of the +// file interleave and one of them is lost. +func TestMutationPathLockSerializesAliasSpellings(t *testing.T) { + base := t.TempDir() + realDir := filepath.Join(base, "real") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatal(err) + } + aliasDir := filepath.Join(base, "alias") + if err := os.Symlink(realDir, aliasDir); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + realPath := filepath.Join(realDir, "a.go") + if err := os.WriteFile(realPath, []byte("package a\n"), 0o644); err != nil { + t.Fatal(err) + } + + aliases := []string{filepath.Join(aliasDir, "a.go")} + if pathkey.CaseInsensitivePaths { + aliases = append(aliases, filepath.Join(realDir, "A.go")) + } + for _, alias := range aliases { + t.Run(filepath.Base(filepath.Dir(alias))+"/"+filepath.Base(alias), func(t *testing.T) { + _, release, err := acquireMutationPath(context.Background(), realPath) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, aliasRelease, err := acquireMutationPath(ctx, alias) + if err == nil { + aliasRelease() + release() + t.Fatalf("%s acquired the mutation lock while %s held it", alias, realPath) + } + if !errors.Is(err, context.DeadlineExceeded) { + release() + t.Fatalf("alias acquire error = %v, want it to wait for the holder", err) + } + release() + + // One request naming both spellings takes the lock once. + ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, both, err := acquireMutationPaths(ctx, []string{realPath, alias}) + if err != nil { + t.Fatalf("acquiring both spellings in one request: %v", err) + } + both() + }) + } +} + +func TestMutationPathsCheckEveryRequestedSpellingBeforeDeduplication(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "a.go") + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relative, err := filepath.Rel(cwd, path) + if err != nil { + t.Fatal(err) + } + if pathkey.MutationKey(relative) != pathkey.MutationKey(path) { + t.Fatal("test paths must name the same mutation key") + } + ctx := withCheckoutMutation(context.Background(), &fakeCheckoutMutationLifecycle{}, root) + _, release, err := acquireMutationPaths(ctx, []string{path, relative}) + if release != nil { + release() + } + if err == nil { + t.Fatal("deduplication bypassed checkout admission for a relative requested spelling") + } + if refs := mutationRefs(path); refs != 0 { + t.Fatalf("refused mutation set retained %d lock references", refs) + } +} diff --git a/internal/mcp/mutation_phase_record_test.go b/internal/mcp/mutation_phase_record_test.go index 23fc7e4d1..af2fe557d 100644 --- a/internal/mcp/mutation_phase_record_test.go +++ b/internal/mcp/mutation_phase_record_test.go @@ -53,7 +53,7 @@ func canonicalTimingContext() context.Context { func commitTimingFixture(t *testing.T, s *Server, ctx context.Context, path, content string) *mutationCommitRecord { t.Helper() - record, err := s.commitFileMutation(ctx, "write_file", "", "", filepath.Base(path), path, []byte(content), 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", filepath.Base(path), path, []byte(content), 0o600) require.NoError(t, err) return record } diff --git a/internal/mcp/mutation_release_wait_test.go b/internal/mcp/mutation_release_wait_test.go new file mode 100644 index 000000000..fdba871c9 --- /dev/null +++ b/internal/mcp/mutation_release_wait_test.go @@ -0,0 +1,71 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +type pathReleaseWatcher struct { + mutationTestWatcher + admitted chan struct{} +} + +func (w pathReleaseWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + ticket, err := w.mutationTestWatcher.EnqueueToolMutation(ctx, path) + close(w.admitted) + return ticket, err +} + +func TestEditFileReleasesPathLockBeforeGraphWait(t *testing.T) { + for _, tool := range []string{"edit_file", "write_file"} { + t.Run(tool, func(t *testing.T) { + s := newMutationServer() + path := filepath.Join(t.TempDir(), "held.txt") + require.NoError(t, os.WriteFile(path, []byte("before"), 0o644)) + done := make(chan indexer.MutationResult, 1) + admitted := make(chan struct{}) + s.SetWatcher(pathReleaseWatcher{mutationTestWatcher: mutationTestWatcher{done: done}, admitted: admitted}) + s.mutationReindexWait = 5 * time.Second + handlerDone := make(chan error, 1) + go func() { + args := map[string]any{"path": path, "content": "after", "old_string": "before", "new_string": "after"} + var result *mcp.CallToolResult + var err error + if tool == "edit_file" { + result, err = s.handleEditFile(t.Context(), editFileRequest(args)) + } else { + result, err = s.handleWriteFile(t.Context(), writeFileRequest(args)) + } + if err == nil && result.IsError { + err = fmt.Errorf("handler refused: %s", resultText(result)) + } + handlerDone <- err + }() + select { + case <-admitted: + case <-time.After(2 * time.Second): + t.Fatal("mutation never reached graph admission") + } + // The graph is deliberately still blocked. Another writer must + // acquire this path before we release the admitted ticket. + waitCtx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + _, release, err := acquireMutationPath(waitCtx, path) + cancel() + if release != nil { + release() + } + done <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + close(done) + require.NoError(t, <-handlerDone) + require.NoError(t, err, "a graph wait must not retain the source path lock") + }) + } +} diff --git a/internal/mcp/mutation_replay_wait.go b/internal/mcp/mutation_replay_wait.go new file mode 100644 index 000000000..28404f0e7 --- /dev/null +++ b/internal/mcp/mutation_replay_wait.go @@ -0,0 +1,86 @@ +package mcp + +import ( + "context" + "fmt" + "time" +) + +// closeResponseReadyLocked requires the record's write lock. Nil after close +// makes retained, terminal, and eviction paths share one completion signal. +func (r *mutationCommitRecord) closeResponseReadyLocked() { + if r.responseReady != nil { + close(r.responseReady) + r.responseReady = nil + } +} + +// The handler may stop after disk commit without retaining a reply (including +// panic recovery). A retry must learn that terminal unavailability too. +func (r *mutationCommitRecord) finishResponseWait() { + if !r.isPhysicalCommit() { + return + } + r.mu.Lock() + r.closeResponseReadyLocked() + r.mu.Unlock() +} + +// A matching physical commit is already final, but its original full-policy +// reply may still be waiting for publication. Release the source path before +// waiting; cancellation stops only this caller, never the original request. +func (s *Server) replayMutationAfterPathRelease(ctx context.Context, key, fingerprint string, releasePath func()) (map[string]any, string, bool) { + if key == "" { + return nil, "", false + } + record, found := s.mutationCommits.byMutationID(key) + if !found { + return nil, "", false + } + record.mu.RLock() + matchingCommitted := record.owner == s && record.key == key && record.fingerprint == fingerprint && record.disk == mutationDiskCommitted + ready := record.responseReady + pending := record.response == nil + record.mu.RUnlock() + if !matchingCommitted { + return s.replayMutation(key, fingerprint) + } + unavailable := func(reason string) (map[string]any, string, bool) { + return nil, fmt.Sprintf("mutation_id %q already committed; its original response is unavailable (%s). Do not reapply: query mutation_status for receipt %s", key, reason, record.id), true + } + if pending && ready != nil { + releasePath() + if err := ctx.Err(); err != nil { + return unavailable(err.Error()) + } + select { + case <-ctx.Done(): + return unavailable(ctx.Err().Error()) + case <-ready: + } + if err := ctx.Err(); err != nil { + return unavailable(err.Error()) + } + } + // Lock order stays ledger -> record. Copy the immutable retained result + // while the same exact owner is still registered; render live facts later. + s.mutationCommits.mu.Lock() + s.mutationCommits.evictLocked(time.Now()) + if s.mutationCommits.byKey[key] != record || s.mutationCommits.byID[record.id] != record { + s.mutationCommits.mu.Unlock() + return unavailable("receipt evicted or replaced") + } + record.mu.RLock() + if record.owner != s || record.key != key || record.fingerprint != fingerprint || record.disk != mutationDiskCommitted || record.response == nil { + record.mu.RUnlock() + s.mutationCommits.mu.Unlock() + return unavailable("original handler finished without a retained response") + } + response := make(map[string]any, len(record.response)+1) + for field, value := range record.response { + response[field] = value + } + record.mu.RUnlock() + s.mutationCommits.mu.Unlock() + return s.renderMutationReplay(record, response) +} diff --git a/internal/mcp/mutation_status_freshness_test.go b/internal/mcp/mutation_status_freshness_test.go index d98f6ac9f..790f76a0c 100644 --- a/internal/mcp/mutation_status_freshness_test.go +++ b/internal/mcp/mutation_status_freshness_test.go @@ -40,19 +40,16 @@ func TestMutationStatusFallsThroughFreshnessReceiptID(t *testing.T) { } }) - t.Run("missing receipt keeps error and adds field hint", func(t *testing.T) { + t.Run("missing receipt reports lost", func(t *testing.T) { s := newMutationServer() id := newID() res, err := s.handleMutationStatus(context.Background(), request(id)) require.NoError(t, err) - require.True(t, res.IsError) - require.Contains(t, resultText(res), "no mutation recorded for mutation_id "+id) - require.Contains(t, resultText(res), "change.receipt options.receipt="+id) + require.Equal(t, "lost", resultJSON(t, res)["state"]) for _, callerKey := range []string{"mutation-", "mutation-abc", "mutation-1suffix", "mutation-12", "prefix-mutation-1"} { res, err = s.handleMutationStatus(context.Background(), request(callerKey)) require.NoError(t, err) - require.True(t, res.IsError) - require.Equal(t, "no mutation recorded for mutation_id "+callerKey, resultText(res)) + require.Equal(t, "lost", resultJSON(t, res)["state"]) } }) @@ -87,7 +84,7 @@ func TestMutationStatusFallsThroughFreshnessReceiptID(t *testing.T) { if scope.checkoutID == "selected" && scope.incarnation == "current" { require.Equal(t, id, resultJSON(t, res)["receipt"]) } else { - require.True(t, res.IsError) + require.Equal(t, "lost", resultJSON(t, res)["state"]) require.NotContains(t, resultText(res), receipt.path) } } diff --git a/internal/mcp/mutation_tiers.go b/internal/mcp/mutation_tiers.go new file mode 100644 index 000000000..5605ff1af --- /dev/null +++ b/internal/mcp/mutation_tiers.go @@ -0,0 +1,19 @@ +package mcp + +import ( + "sync/atomic" + "time" +) + +func epochMutationSequence() *atomic.Uint64 { + sequence := new(atomic.Uint64) + sequence.Store(uint64(time.Now().UnixNano())) + return sequence +} + +func lostMutationPayload(key, id string) map[string]any { + return map[string]any{ + key: id, "found": false, "state": "lost", "graph_status_terminal": true, + "note": "this process has no retained receipt; read the current file or request a scoped refresh; a lost receipt cannot certify freshness", + } +} diff --git a/internal/mcp/node_source.go b/internal/mcp/node_source.go new file mode 100644 index 000000000..43a886a36 --- /dev/null +++ b/internal/mcp/node_source.go @@ -0,0 +1,154 @@ +package mcp + +import ( + "cmp" + "context" + "errors" + "fmt" + "os" + "slices" + "strings" + + "github.com/zeebo/blake3" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" +) + +// errSymbolRemoved reports that a symbol the graph still lists is no longer +// declared in the file's current bytes: an edit removed it before the index +// caught up. +var errSymbolRemoved = errors.New("symbol was removed from the file since it was indexed") + +// errSymbolAmbiguous reports that a symbol's ID was derived from its position +// and declarations sharing that ID were added or removed since the file was +// indexed, so the current bytes cannot say which of them it is. +var errSymbolAmbiguous = errors.New("symbol cannot be told apart from same-named declarations added or removed since the file was indexed") + +// nodeLinesIn returns node's 1-based line range within content, the bytes +// the caller read for node's file. The stored range is used only while the +// index records exactly these bytes and still holds that range; otherwise the +// bytes are parsed once and the symbol is located again, so a range read from +// one version of a file is never applied to another. +func (s *Server) nodeLinesIn(ctx context.Context, node *graph.Node, content []byte) (int, int, error) { + owner, rel := s.indexerForRel(node.FilePath) + if owner == nil || rel == "" { + return 0, 0, fmt.Errorf("no indexer owns %s", node.FilePath) + } + if owner.IndexedContentMatches(rel, content) { + // A reindex writes a file's nodes before its content identity, so + // the node read now belongs to these bytes. node may have been read + // before that reindex committed. + if live := owner.Graph().GetNode(node.ID); live != nil && + live.StartLine == node.StartLine && live.EndLine == node.EndLine { + return node.StartLine, node.EndLine, nil + } + } + + s.noteAcknowledgementParser(ctx, "source_extraction_for_symbol_location") + parseLap := s.beginAcknowledgementLap(ctx, editAckParseGate) + extracted, err := owner.ExtractSource(ctx, rel, content) + parseLap.finish() + if err != nil { + return 0, 0, fmt.Errorf("locate %s in the current file: %w", node.ID, err) + } + defer extracted.ReleaseTree() + return s.declarationLinesIn(ctx, node, extracted.Nodes) +} + +func (s *Server) declarationLinesIn(ctx context.Context, node *graph.Node, nodes []*graph.Node) (int, int, error) { + _, local, ok := strings.Cut(node.ID, "::") + if !ok { + return 0, 0, fmt.Errorf("symbol %s has no file-local id", node.ID) + } + // A declaration is located by its order among the declarations that + // share its ID base, as the reader that produced node listed them. An + // ID with no position in it is unique, so that is an exact match; a + // positional one pairs only while the count holds. + base := declarationBase(local) + current := declarationsSharing(nodes, base) + if len(current) == 0 { + return 0, 0, errSymbolRemoved + } + stored := declarationsSharing(s.readerFor(ctx).GetFileNodes(node.FilePath), base) + at := slices.IndexFunc(stored, func(n *graph.Node) bool { return n.ID == node.ID }) + if at < 0 || len(stored) != len(current) { + return 0, 0, fmt.Errorf("%w: %s", errSymbolAmbiguous, node.ID) + } + return current[at].StartLine, current[at].EndLine, nil +} + +// declarationBase strips the position an extractor appends to a file-local +// ID to keep same-named declarations apart: _L on a redeclaration +// (the first keeps the bare ID), @ on an object member or anonymous +// class, and @+ on a local binding. +func declarationBase(local string) string { + trimmed := strings.TrimRight(local, "0123456789") + if len(trimmed) == len(local) { + return local + } + for _, marker := range []string{"_L", "@+", "@"} { + if base, ok := strings.CutSuffix(trimmed, marker); ok { + return base + } + } + return local +} + +// declarationsSharing returns the nodes whose file-local ID has base, in +// source order, keeping one node per ID. +func declarationsSharing(nodes []*graph.Node, base string) []*graph.Node { + var out []*graph.Node + for _, n := range nodes { + if _, local, ok := strings.Cut(n.ID, "::"); ok && declarationBase(local) == base { + out = append(out, n) + } + } + slices.SortFunc(out, func(a, b *graph.Node) int { + return cmp.Or(cmp.Compare(a.StartLine, b.StartLine), strings.Compare(a.ID, b.ID)) + }) + return slices.CompactFunc(out, func(a, b *graph.Node) bool { return a.ID == b.ID }) +} + +// nodeSource is a node's current line range and the content it indexes into. +type nodeSource struct { + content string // UTF-16 sources decoded, as the index reads them + startLine int + endLine int +} + +// readNodeSource reads the file at absPath that declares node and resolves +// node's range against exactly the bytes it read. An editor overlay is served +// with the node's own range, since the overlay view parsed that content. +func (s *Server) readNodeSource(ctx context.Context, node *graph.Node, absPath string) (nodeSource, error) { + if content, ok := s.overlayContentFor(ctx, absPath); ok { + return nodeSource{content: content, startLine: node.StartLine, endLine: node.EndLine}, nil + } + raw, err := os.ReadFile(absPath) + if err != nil { + return nodeSource{}, err + } + start, end, err := s.nodeLinesIn(ctx, node, raw) + if err != nil { + return nodeSource{}, err + } + return nodeSource{content: string(indexer.DecodeUTF16Source(raw)), startLine: start, endLine: end}, nil +} + +// symbolLinesUnderLock is the write-side resolver. The source snapshot and +// declaration identity are checked only while this request owns the path. +func (s *Server) symbolLinesUnderLock(ctx context.Context, node *graph.Node, absPath string, content []byte) (int, int, error) { + if !mutationPathAdmitted(ctx, pathkey.MutationKey(absPath)) { + return 0, 0, fmt.Errorf("%w: symbol path is not held", errMutationNotApplied) + } + key := pathkey.MutationKey(absPath) + entry := pendingFileProjections.snap.Load().entries[key] + if entry != nil && !entry.tombstone && entry.graphPath == node.FilePath && entry.rawHash == blake3.Sum256(content) { + start, end, err := s.declarationLinesIn(ctx, node, entry.layer.FileNodes(node.FilePath)) + if pendingFileProjections.snap.Load().entries[key] == entry { + return start, end, err + } + } + return s.nodeLinesIn(ctx, node, content) +} diff --git a/internal/mcp/node_source_test.go b/internal/mcp/node_source_test.go new file mode 100644 index 000000000..d0524e597 --- /dev/null +++ b/internal/mcp/node_source_test.go @@ -0,0 +1,135 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" +) + +// shiftTestServerMain inserts two lines above main in the setupTestServer +// fixture without reindexing, moving helper from line 11 to line 13. The old +// range then covers main's closing brace. +func shiftTestServerMain(t *testing.T, dir string) { + t.Helper() + path := filepath.Join(dir, "main.go") + src, err := os.ReadFile(path) + require.NoError(t, err) + shifted := strings.Replace(string(src), "func main() {", "// one\n// two\nfunc main() {", 1) + require.NoError(t, os.WriteFile(path, []byte(shifted), 0o644)) +} + +func TestReadNodeSourceResolvesCurrentLines(t *testing.T) { + srv, dir := setupTestServer(t) + ctx := context.Background() + mainGo := filepath.Join(dir, "main.go") + stored := srv.engineFor(ctx).GetSymbol("main.go::helper") + require.NotNil(t, stored) + require.Equal(t, 11, stored.StartLine) + // The handler's copy, which a later reindex does not update. + node := *stored + + read := func() (nodeSource, uint64, error) { + before := parser.ParseCount() + src, err := srv.readNodeSource(ctx, &node, mainGo) + return src, parser.ParseCount() - before, err + } + + src, parses, err := read() + require.NoError(t, err) + require.Equal(t, [2]int{11, 11}, [2]int{src.startLine, src.endLine}) + require.Zero(t, parses, "bytes the index recorded reuse the stored range without parsing") + + shiftTestServerMain(t, dir) + src, parses, err = read() + require.NoError(t, err) + require.Equal(t, [2]int{13, 13}, [2]int{src.startLine, src.endLine}) + require.Equal(t, "func helper() {}", strings.Split(src.content, "\n")[src.startLine-1]) + require.EqualValues(t, 1, parses, "unrecorded bytes are parsed once to locate the symbol") + + // A reindex that commits after the handler read node records these + // bytes, so they match the index while node still holds the old range. + require.NoError(t, srv.indexer.IndexFile(mainGo)) + src, _, err = read() + require.NoError(t, err) + require.Equal(t, [2]int{13, 13}, [2]int{src.startLine, src.endLine}) + require.Equal(t, "func helper() {}", strings.Split(src.content, "\n")[src.startLine-1]) + + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n\nfunc main() {}\n"), 0o644)) + _, _, err = read() + require.ErrorIs(t, err, errSymbolRemoved) +} + +// TestReadNodeSourcePairsPositionalIDs covers IDs derived from where a +// declaration sits: a redeclared Go init gains _L while the first keeps +// the bare ID, and a TypeScript object member carries @. +func TestReadNodeSourcePairsPositionalIDs(t *testing.T) { + srv, dir := setupTestServer(t) + ctx := context.Background() + write := func(name, src string) string { + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(src), 0o644)) + return path + } + symbol := func(id string) *graph.Node { + node := srv.engineFor(ctx).GetSymbol(id) + require.NotNil(t, node, id) + return node + } + firstLine := func(node *graph.Node, path string) (string, error) { + src, err := srv.readNodeSource(ctx, node, path) + if err != nil { + return "", err + } + return strings.Split(src.content, "\n")[src.startLine-1], nil + } + + const inits = "package main\n\nfunc init() { first() }\n\nfunc init() { second() }\n" + initsGo := write("inits.go", inits) + require.NoError(t, srv.indexer.IndexFile(initsGo)) + first, second := symbol("inits.go::init"), symbol("inits.go::init_L5") + + write("inits.go", strings.Replace(inits, "package main\n", "package main\n\n// shifted\n", 1)) + for node, want := range map[*graph.Node]string{first: "func init() { first() }", second: "func init() { second() }"} { + got, err := firstLine(node, initsGo) + require.NoError(t, err, node.ID) + require.Equal(t, want, got, node.ID) + } + + // A third init above the others takes the bare ID and renumbers the rest, + // so neither ID nor position says which stored init is which. + write("inits.go", strings.Replace(inits, "package main\n", "package main\n\nfunc init() { third() }\n", 1)) + for _, node := range []*graph.Node{first, second} { + _, err := firstLine(node, initsGo) + require.ErrorIs(t, err, errSymbolAmbiguous, node.ID) + } + + const members = "export const handlers = {\n onClick: () => 1,\n};\n" + membersTS := write("members.ts", members) + require.NoError(t, srv.indexer.IndexFile(membersTS)) + onClick := symbol("members.ts::handlers.onClick@2") + write("members.ts", "// one\n// two\n"+members) + got, err := firstLine(onClick, membersTS) + require.NoError(t, err) + require.Equal(t, " onClick: () => 1,", got) +} + +func TestDeclarationBase(t *testing.T) { + for local, want := range map[string]string{ + "init": "init", + "init_L12": "init", + "v2_L7": "v2", + "handlers.onClick@4": "handlers.onClick", + "Run#local:err@+3": "Run#local:err", + "Base64": "Base64", + "Point.X": "Point.X", + } { + require.Equal(t, want, declarationBase(local), local) + } +} diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index 09ec77aec..f47f35ca8 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -229,11 +229,6 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // read that must stay reachable while publication is pending, and the // tools that must not be hostage to the binding they exist to fix. viewless := catalogOnlyCheckoutControl(controlOperation) || viewlessCatalogTool(legacyName) - // A source mutation preempts background writers while it selects and - // enters its mutation lane. Publication waits temporarily release that - // intent so the previous edit can finish the route this one needs. - ctx, releaseWriteIntent := s.withSourceMutationWriteIntent(ctx, req.Params.Name) - defer releaseWriteIntent() ctx = s.withMutationPublicationStamps(ctx, req.Params.Name) byteCtx, byteView, releaseByte, byteRefusal := s.prepareByteEditAuthority(ctx, &req, selector, freshness, capabilities) if byteRefusal != nil { @@ -265,6 +260,10 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay ctx = withRequestView(ctx, view) defer view.close() } + // The selected route identifies whether this handler writes the view + // store. File-only canonical edits never announce a store writer. + ctx, releaseWriteIntent := s.withSourceMutationWriteIntent(ctx, legacyName) + defer releaseWriteIntent() indexer.StampPublicationPhase(ctx, indexer.PublicationViewResolved) // Tell the deadline firewall what this call is reading and what // repositories it is admitted to. If it stops waiting for this handler @@ -276,6 +275,9 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // Publishing is a no-op when no firewall frame is above us (an // unbounded call). noteRetainedRequest(ctx, view, scope) + if refusal := s.awaitWatchedFreshness(ctx, legacyName, req, freshness); refusal != nil { + return refusal, nil + } if requireExactView && view != nil && view.rider != nil && !view.rider.Exact { return mcp.NewToolResultError(graphview.NewViewError(graphview.CodeViewBuilding, "the requested exact checkout view is unavailable; retry after publication; no fallback was served").Error()), nil @@ -354,6 +356,12 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay ctx = withContractCoreReadErrors(ctx) } indexer.StampPublicationPhase(ctx, indexer.PublicationHandlerStarted) + ctx, releaseExplicitFreshness := s.captureExplicitFreshness(ctx, req) + if releaseExplicitFreshness != nil { + defer releaseExplicitFreshness() + } + ctx = s.captureToolFreshness(ctx) + ctx, req = s.reserveToolFreshnessBudget(ctx, req.Params.Name, req) res, hErr := s.invokeAcknowledgedLeaf(ctx, req, h) if hErr == nil && res != nil && !res.IsError && lateExactnessRefusalIsSafe(req.Params.Name) { if err := contractCoreReadError(ctx); err != nil { @@ -435,13 +443,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // small `freshness` block so the agent knows the graph view may lag // the working tree. Omitted (zero cost) for the common fresh case. if hErr == nil { - if rider := s.freshnessRiderFor(req.Params.Name, req); rider != nil { - res = decorateResultWithFreshness(res, rider) - } else if isFreshnessListTool(req.Params.Name) { - // List tools get a per-file sweep: any hit whose file drifted - // or vanished on disk is flagged with per-repo provenance. - res = s.decorateListResultWithFreshness(res) - } + res = s.decorateToolFreshness(ctx, req.Params.Name, req, res) // Which view answered rides in that same block: a response that // came from somewhere other than the base — or fell back to it — // must say so where the caller already looks for provenance. @@ -467,6 +469,9 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // (momentum.go). No-op for non-read tools and error results. if hErr == nil { res = s.maybeAttachMomentumNote(ctx, req.Params.Name, res) + if _, reserved := ctx.Value(toolFreshnessBudgetKey{}).(int); reserved { + res = fitToolFreshnessBudget(ctx, req, res) + } } return res, hErr })) diff --git a/internal/mcp/overlay_contract_bundle_test.go b/internal/mcp/overlay_contract_bundle_test.go new file mode 100644 index 000000000..5cc831fbc --- /dev/null +++ b/internal/mcp/overlay_contract_bundle_test.go @@ -0,0 +1,41 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/search" +) + +func TestContractRuntimeSearchPreservesSessionOverlayBundles(t *testing.T) { + for _, contract := range []bool{false, true} { + name := "plain_overlay" + if contract { + name = "contract_runtime" + } + t.Run(name, func(t *testing.T) { + old := &graph.Node{ID: "repo/edit.go::Widget", Name: "Widget", Kind: graph.KindFunction, FilePath: "repo/edit.go", StartLine: 3} + gone := &graph.Node{ID: "repo/edit.go::WidgetGone", Name: "WidgetGone", Kind: graph.KindFunction, FilePath: "repo/edit.go", StartLine: 8} + backend := &searchSymbolsContextBackend{bundles: []search.SymbolBundle{{Node: old, Score: 10}, {Node: gone, Score: 9}}} + server := newSearchSymbolsContextServer(backend) + base := server.graph.(*graph.Graph) + base.AddNode(old) + base.AddNode(gone) + layer := graph.NewOverlayLayer() + layer.MarkFile(old.FilePath, false) + replacement := *old + replacement.StartLine = 44 + layer.AddNode(old.FilePath, &replacement) + layer.MarkRemoved(gone.Name, gone.ID) + if contract { + server.contractAnalysisRuntime = &ContractAnalysisRuntime{} + } + ctx := WithOverlayView(t.Context(), graph.NewOverlaidView(base, layer)) + got := server.engineFor(ctx).GatherSymbolCandidates("Widget", 5, query.QueryOptions{SkipInnerRerank: true, SkipVectorChannel: true}, nil) + require.Len(t, got, 1, "the actual request engine cannot revive an overlay-deleted bundle through its runtime reader wrapper") + require.Same(t, &replacement, got[0].Node, "the actual request engine must serve the session replacement") + }) + } +} diff --git a/internal/mcp/overlay_view.go b/internal/mcp/overlay_view.go index faab2e970..c10dd2b76 100644 --- a/internal/mcp/overlay_view.go +++ b/internal/mcp/overlay_view.go @@ -227,11 +227,15 @@ func (s *Server) engineFor(ctx context.Context) *query.Engine { for _, analysis := range binding.views { layers = append(layers, query.ViewLayerSource{Search: analysis.SearchBackend(), Layer: binding.composedLayer}) } - return s.engine.WithComposedView(reader, layers, ctx, view.excludesBaseCorpus()) + ownership := s.requestLayers(ctx) + if binding.composedLayer != nil { + ownership = append(ownership, binding.composedLayer) + } + return s.engine.WithComposedView(reader, layers, ctx, view.excludesBaseCorpus()).WithLayers(ownership) } if s.contractAnalysisRuntime != nil { view := requestViewFromContext(ctx) - return s.engine.WithComposedView(s.readerFor(ctx), view.candidateLayers(), ctx, view.excludesBaseCorpus()) + return s.engine.WithComposedView(s.readerFor(ctx), view.candidateLayers(), ctx, view.excludesBaseCorpus()).WithLayers(s.requestLayers(ctx)) } // A routed or overlaid request engine is already a per-request clone, and // it carries the request's lifetime so its long graph walks stop when the @@ -240,10 +244,10 @@ func (s *Server) engineFor(ctx context.Context) *query.Engine { // context through QueryOptions.Context instead. view := requestViewFromContext(ctx) if v := OverlayViewFromContext(ctx); v != nil { - return s.engine.WithViewLayersContext(v, view.candidateLayers(), ctx) + return s.engine.WithViewLayersContext(v, view.candidateLayers(), ctx).WithLayers(s.requestLayers(ctx)) } if view.routed() { - return s.engine.WithComposedView(view.reader, view.candidateLayers(), ctx, view.excludesBaseCorpus()) + return s.engine.WithComposedView(view.reader, view.candidateLayers(), ctx, view.excludesBaseCorpus()).WithLayers(s.requestLayers(ctx)) } return s.engine } @@ -1527,3 +1531,18 @@ func (s *Server) validateOverlaySnapshotDrift(ctx context.Context, snapshot *ove return nil } + +// requestLayers carries the same ownership claims as readerFor, independently +// of runtime wrappers. Candidate corpora and row ownership are separate inputs. +func (s *Server) requestLayers(ctx context.Context) []graph.OverlayLayerReader { + var layers []graph.OverlayLayerReader + for _, source := range requestViewFromContext(ctx).candidateLayers() { + if source.Layer != nil { + layers = append(layers, source.Layer) + } + } + if overlay := OverlayViewFromContext(ctx); overlay != nil && overlay.Layer() != nil { + layers = append(layers, overlay.Layer()) + } + return layers +} diff --git a/internal/mcp/parse_gate.go b/internal/mcp/parse_gate.go index 7f1a7314b..aa68ce314 100644 --- a/internal/mcp/parse_gate.go +++ b/internal/mcp/parse_gate.go @@ -104,7 +104,7 @@ type parseGateResult struct { Checked bool // the language was parseable and the gate ran Blocked bool // newContent is a regression over oldContent BecameBinary bool // the edit turns a text file into NUL-bearing binary content - OldErrors int // parse errors in the pre-edit content + OldErrors int // parse errors in the pre-edit content; counted only when NewErrors > 0 NewErrors int // parse errors in the candidate content Language string // gate language (may be set even when Checked is false) } @@ -132,6 +132,12 @@ func parseGateEnabled() bool { // to text, binary to binary — carry no parse-error baseline at all, so // they are never a regression. func checkParseGate(path string, oldContent, newContent []byte) parseGateResult { + return checkParseGateWithVerdict(path, oldContent, newContent, false, 0) +} + +// A supplied verdict describes the exact candidate bytes. Unknown input uses +// the original parse; binary transitions and old-byte comparisons stay shared. +func checkParseGateWithVerdict(path string, oldContent, newContent []byte, checked bool, candidateErrors int) parseGateResult { lang := parseGateLanguage(path) if lang == "" { return parseGateResult{Language: lang} @@ -148,12 +154,17 @@ func checkParseGate(path string, oldContent, newContent []byte) parseGateResult Language: lang, } } - newErrs, newOK := parseErrorCount(lang, newContent) + newErrs, newOK := candidateErrors, checked && candidateErrors >= 0 + if !newOK { + newErrs, newOK = parseErrorCount(lang, newContent) + } if !newOK { return parseGateResult{Language: lang} } + // A clean candidate cannot regress, so the prior bytes are parsed only + // when there are errors to compare against: a clean edit costs one parse. oldErrs := 0 - if len(oldContent) > 0 { + if newErrs > 0 && len(oldContent) > 0 { if e, ok := parseErrorCount(lang, oldContent); ok { oldErrs = e } @@ -186,8 +197,8 @@ func parseGateInfo(r parseGateResult, allowed bool) map[string]any { if !r.Checked { return nil } - if r.OldErrors == r.NewErrors && r.NewErrors == 0 && !r.Blocked { - return nil // nothing to report — a clean file stayed clean + if r.NewErrors == 0 && !r.Blocked { + return nil // nothing to report — the candidate is clean } m := map[string]any{ "language": r.Language, diff --git a/internal/mcp/parse_gate_test.go b/internal/mcp/parse_gate_test.go index ff6d765c5..a174f1f9c 100644 --- a/internal/mcp/parse_gate_test.go +++ b/internal/mcp/parse_gate_test.go @@ -1,8 +1,15 @@ package mcp import ( + "context" + "path/filepath" "strings" "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/parser" ) func TestParseGateLanguage(t *testing.T) { @@ -157,3 +164,31 @@ func TestParseGateInfo(t *testing.T) { t.Errorf("binary gate info = %v, want blocked:true became_binary:true", info) } } + +// TestEditParsesOncePerCleanEdit pins what the gate costs on the edit ack +// path: a candidate with no parse errors is parsed once, and the prior bytes +// are parsed only when there are candidate errors to compare against. The +// watcher admits the reindex, so the counted parses are the gate's own. +func TestEditParsesOncePerCleanEdit(t *testing.T) { + srv, dir := setupTestServer(t) + srv.SetWatcher(mutationTestWatcher{}) + mainGo := filepath.Join(dir, "main.go") + + edit := func(oldString, newString string) (uint64, *mcplib.CallToolResult) { + t.Helper() + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{"path": mainGo, "old_string": oldString, "new_string": newString} + before := parser.ParseCount() + res, err := srv.handleEditFile(context.Background(), req) + require.NoError(t, err) + return parser.ParseCount() - before, res + } + + parses, res := edit("func helper() {}", "func helper() { _ = 1 }") + require.False(t, res.IsError, "clean edit refused: %v", res.Content) + require.EqualValues(t, 1, parses, "a clean edit parses only the candidate") + + parses, res = edit("func helper() { _ = 1 }", "func helper() { _ = }") + require.True(t, res.IsError, "the gate must refuse a syntax regression") + require.EqualValues(t, 2, parses, "a broken candidate is compared against the prior bytes") +} diff --git a/internal/mcp/pending_projection.go b/internal/mcp/pending_projection.go new file mode 100644 index 000000000..db21315dd --- /dev/null +++ b/internal/mcp/pending_projection.go @@ -0,0 +1,434 @@ +package mcp + +import ( + "context" + "errors" + "io/fs" + "maps" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/zeebo/blake3" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" +) + +// The registry refuses a publish over either cap and never evicts a live +// entry to make room: an evicted entry would silently serve stale code. The +// byte cap is the feature's whole memory allowance; file sizes vary too much +// for the file cap alone to bound memory. +const ( + projectionMaxFiles = 512 + projectionMaxBytes = 64 << 20 + + // Retained heap of one projected node and edge, measured with + // BenchmarkProjectionRetainedHeap, for the byte cap. + projectionNodeBytes = 1440 + projectionEdgeBytes = 540 +) + +// pendingFileProjections holds the projection of every file a tool wrote +// whose bytes the store has not caught up with yet. Like the mutation path +// locks it is process-wide and keyed by pathkey.MutationKey. +var pendingFileProjections = newPendingProjections() + +// pendingProjections is a copy-on-write map from a file's mutation key to the +// projection of its latest tool-written bytes. Readers load the snapshot +// without locking; publishers hold mu only to copy the map and swap it, never +// while building an entry. +type pendingProjections struct { + enabled bool + + mu sync.Mutex + snap atomic.Pointer[projectionSnapshot] + refused map[projectionRefusal]int64 // guarded by mu + + published atomic.Int64 + retired atomic.Int64 + externalWrites atomic.Int64 + divergence atomic.Int64 +} + +type projectionSnapshot struct { + entries map[string]*projectionEntry + bytes int64 +} + +// projectionEntry is one file's projection. Everything but suspect is +// immutable once published. +type projectionEntry struct { + key string + absPath string + graphPath string + repoPrefix string + commitSeq uint64 + // hash equals the store's FileMetaRow.ContentHash once the store holds + // these bytes; rawHash covers the bytes on disk for the suspect re-check. + hash string + rawHash [32]byte + contentSHA256 string + disk diskStamp + language string + layer *graph.OverlayLayer + parseErrors int + fileView *fileViewEvidence + parseChecked bool + buildTime time.Duration + size int64 + tombstone bool + // suspect is set by a watcher event for the path; the next lookup + // re-reads the file before serving the entry. + suspect atomic.Bool +} + +// projectionRefusal is why a publish left the live entries as they were. +type projectionRefusal string + +const ( + projectionDisabled projectionRefusal = "disabled" + projectionPathCase projectionRefusal = "path_case" + projectionSuperseded projectionRefusal = "superseded" + projectionOverFiles projectionRefusal = "max_files" + projectionOverBytes projectionRefusal = "max_bytes" +) + +func (r projectionRefusal) Error() string { return "projection not published: " + string(r) } + +// errProjectionExternalWrite reports that the file no longer holds the bytes +// its entry was built from. The entry is dropped; the store answers instead. +var errProjectionExternalWrite = errors.New("projection: file changed on disk since it was projected") + +func newPendingProjections() *pendingProjections { + p := &pendingProjections{ + enabled: os.Getenv("GORTEX_PROJECTION") != "0", + refused: make(map[projectionRefusal]int64), + } + p.snap.Store(&projectionSnapshot{entries: map[string]*projectionEntry{}}) + return p +} + +// diskStamp is what a lookup compares to notice a write the projection did +// not see: size, modification time and file identity. A nil info stamps a +// file that does not exist. +type diskStamp struct { + info os.FileInfo +} + +// statDiskStamp stamps path without following a leaf symlink, matching +// pathkey.MutationKey. +func statDiskStamp(path string) (diskStamp, error) { + info, err := os.Lstat(path) + if errors.Is(err, fs.ErrNotExist) { + return diskStamp{}, nil + } + if err != nil { + return diskStamp{}, err + } + return freezeDiskStampIdentity(path, info) +} + +func freezeDiskStampIdentity(path string, info os.FileInfo) (diskStamp, error) { + // Go's Windows Lstat may defer loading file IDs until SameFile. Freeze + // them while this stamp is taken, before a replacement can reuse the + // saved path. SameFile retains Lstat's no-follow semantics and performs + // no content open on Unix, including for FIFOs. Refuse an unknown ID. + if !os.SameFile(info, info) { + return diskStamp{}, &os.PathError{Op: "stat", Path: path, Err: errors.New("projection: file identity unavailable")} + } + return diskStamp{info: info}, nil +} + +func (s diskStamp) matches(other diskStamp) bool { + if s.info == nil || other.info == nil { + return s.info == nil && other.info == nil + } + return s.info.Size() == other.info.Size() && + s.info.ModTime().Equal(other.info.ModTime()) && + os.SameFile(s.info, other.info) +} + +// projectFile builds the entry for raw, the bytes a tool wrote to absPath as +// commit commitSeq, and disk, the stamp of that write. It reads no store +// state. +func projectFile(ctx context.Context, idx *indexer.Indexer, absPath string, raw []byte, commitSeq uint64, disk diskStamp) (*projectionEntry, error) { + projection, err := idx.ProjectSource(ctx, absPath, raw) + if err != nil { + return nil, err + } + return newProjectionEntry(absPath, idx.RepoPrefix(), projection, raw, commitSeq, disk), nil +} + +func newProjectionEntry(absPath, repoPrefix string, projection *indexer.Projection, raw []byte, commitSeq uint64, disk diskStamp) *projectionEntry { + layer := graph.NewOverlayLayer() + layer.MarkFile(projection.GraphPath, false) + for _, n := range projection.Nodes { + layer.AddNode(projection.GraphPath, n) + } + for _, e := range projection.Edges { + layer.AddEdge(e) + } + return &projectionEntry{ + key: pathkey.MutationKey(absPath), + absPath: absPath, + graphPath: projection.GraphPath, + repoPrefix: repoPrefix, + commitSeq: commitSeq, + hash: projection.ContentHash, + rawHash: blake3.Sum256(raw), + disk: disk, + language: projection.Language, + layer: layer, + parseErrors: projection.ParseErrors, + parseChecked: projection.ParseChecked, + buildTime: projection.BuildTime, + size: int64(len(projection.Nodes))*projectionNodeBytes + int64(len(projection.Edges))*projectionEdgeBytes, + } +} + +// committedCopy gives each physical execution its own immutable entry. +// The owned layer is immutable and may be shared; the suspect atomic and disk +// identity must never be copied or rewritten on an already-published entry. +func (e *projectionEntry) committedCopy(sequence uint64, disk diskStamp) *projectionEntry { + copy := &projectionEntry{ + key: e.key, absPath: e.absPath, graphPath: e.graphPath, repoPrefix: e.repoPrefix, + commitSeq: sequence, hash: e.hash, rawHash: e.rawHash, contentSHA256: e.contentSHA256, disk: disk, language: e.language, + layer: e.layer, fileView: e.fileView, parseErrors: e.parseErrors, parseChecked: e.parseChecked, + buildTime: e.buildTime, size: e.size, tombstone: e.tombstone, + } + if e.suspect.Load() { + copy.suspect.Store(true) + } + return copy +} + +// newProjectionTombstone is the entry for a file a tool removed: its layer +// hides every symbol the store still holds for the path. +func newProjectionTombstone(absPath, graphPath, repoPrefix string, commitSeq uint64) *projectionEntry { + layer := graph.NewOverlayLayer() + layer.MarkFile(graphPath, true) + return &projectionEntry{ + key: pathkey.MutationKey(absPath), + absPath: absPath, + graphPath: graphPath, + repoPrefix: repoPrefix, + commitSeq: commitSeq, + layer: layer, + tombstone: true, + } +} + +// publish makes e the live projection of its file, replacing an older entry +// at once. It refuses, leaving every live entry in place, when publishing is +// off, when e is older than the live entry, when e names the live entry's +// file in a spelling that matches only after case folding, and when e would +// take the registry over a cap. +func (p *pendingProjections) publish(e *projectionEntry) error { + if !p.enabled { + return projectionDisabled + } + p.mu.Lock() + defer p.mu.Unlock() + cur := p.snap.Load() + prev := cur.entries[e.key] + bytes := cur.bytes + e.size + if prev != nil { + bytes -= prev.size + } + var refusal projectionRefusal + switch { + case prev != nil && prev.graphPath != e.graphPath: + refusal = projectionPathCase + case prev != nil && prev.commitSeq > e.commitSeq: + refusal = projectionSuperseded + case prev == nil && len(cur.entries) >= projectionMaxFiles: + refusal = projectionOverFiles + case bytes > projectionMaxBytes: + refusal = projectionOverBytes + } + if refusal != "" { + p.refused[refusal]++ + return refusal + } + // A watcher event that landed between the write and this publish + // marked the entry being replaced; it applies to these bytes too. + if prev != nil && prev.suspect.Load() { + e.suspect.Store(true) + } + next := &projectionSnapshot{entries: maps.Clone(cur.entries), bytes: bytes} + next.entries[e.key] = e + p.snap.Store(next) + p.published.Add(1) + return nil +} + +// lookup returns the live projection of path, or nil when there is none. +// The file is stat-ed before an entry is served; when it no longer holds the +// projected bytes the entry is dropped and errProjectionExternalWrite +// returned, so the caller re-projects or reads the store. +func (p *pendingProjections) lookup(path string) (*projectionEntry, error) { + snap := p.snap.Load() + if len(snap.entries) == 0 { + return nil, nil + } + e := snap.entries[pathkey.MutationKey(path)] + if e == nil { + return nil, nil + } + if !e.onDisk() { + p.drop(e) + p.externalWrites.Add(1) + return nil, errProjectionExternalWrite + } + return e, nil +} + +func (e *projectionEntry) onDisk() bool { + stamp, err := statDiskStamp(e.absPath) + if err != nil || !e.disk.matches(stamp) { + return false + } + if e.tombstone || !e.suspect.Swap(false) { + return true + } + raw, err := os.ReadFile(e.absPath) + return err == nil && blake3.Sum256(raw) == e.rawHash +} + +// markSuspect makes the next lookup of path re-read the file before serving +// its entry. The watcher calls it for every event, before debouncing. +func (p *pendingProjections) markSuspect(path string) { + snap := p.snap.Load() + if len(snap.entries) == 0 { + return + } + if e := snap.entries[pathkey.MutationKey(path)]; e != nil { + e.suspect.Store(true) + } +} + +func (p *pendingProjections) drop(e *projectionEntry) { + p.dropAll([]*projectionEntry{e}) +} + +// dropAll removes each entry that is still the live one for its file; an +// entry already replaced by a newer publish stays replaced. +func (p *pendingProjections) dropAll(entries []*projectionEntry) int { + if len(entries) == 0 { + return 0 + } + p.mu.Lock() + defer p.mu.Unlock() + cur := p.snap.Load() + next := &projectionSnapshot{bytes: cur.bytes} + dropped := 0 + for _, e := range entries { + if cur.entries[e.key] != e { + continue + } + if next.entries == nil { + next.entries = maps.Clone(cur.entries) + } + delete(next.entries, e.key) + next.bytes -= e.size + dropped++ + } + if dropped > 0 { + p.snap.Store(next) + } + return dropped +} + +// projectionStore is what retirement reads: a file's content identity, then +// its nodes. +type projectionStore interface { + graph.FileMetaPathReader + GetFileNodes(filePath string) []*graph.Node +} + +// retirePaths verifies only the physical files in one publication frontier. +// Filtering happens before store access: equal graph paths/prefixes/hashes in +// another checkout are not evidence about that checkout's backend. +func (p *pendingProjections) retirePaths(store projectionStore, absPaths []string) int { + if len(absPaths) == 0 { + return 0 + } + selected := make(map[string]struct{}, len(absPaths)) + for _, path := range absPaths { + selected[pathkey.MutationKey(path)] = struct{}{} + } + return p.retireSelected(store, selected) +} + +// retire retains the explicit all-entry helper for tests/whole-owner callers. +// Runtime publication callbacks use retirePaths with their physical frontier. +func (p *pendingProjections) retire(store projectionStore) int { + return p.retireSelected(store, nil) +} + +// A receipt alone is never retirement evidence. Both entry points preserve +// durable hash -> node comparison -> durable hash and pointer-fenced COW drop. +func (p *pendingProjections) retireSelected(store projectionStore, selected map[string]struct{}) int { + snap := p.snap.Load() + if len(snap.entries) == 0 { + return 0 + } + byRepo := make(map[string][]*projectionEntry) + for key, e := range snap.entries { + if selected != nil { + if _, ok := selected[key]; !ok { + continue + } + } + byRepo[e.repoPrefix] = append(byRepo[e.repoPrefix], e) + } + var caughtUp []*projectionEntry + for prefix, entries := range byRepo { + candidates := storeHolds(store, prefix, entries) + diverged := make(map[*projectionEntry]bool, len(candidates)) + for _, e := range candidates { + stored := store.GetFileNodes(e.graphPath) + diverged[e] = len(indexer.DiffProjectedNodes(e.layer.FileNodes(e.graphPath), stored)) > 0 + } + for _, e := range storeHolds(store, prefix, candidates) { + if diverged[e] { + p.divergence.Add(1) + } + caughtUp = append(caughtUp, e) + } + } + retired := p.dropAll(caughtUp) + p.retired.Add(int64(retired)) + return retired +} + +// storeHolds returns the entries whose content the store holds now. +func storeHolds(store projectionStore, prefix string, entries []*projectionEntry) []*projectionEntry { + if len(entries) == 0 { + return nil + } + paths := make([]string, len(entries)) + for i, e := range entries { + paths[i] = e.graphPath + } + rows, err := store.FileMetasByPaths(prefix, paths) + if err != nil { + return nil + } + var held []*projectionEntry + for _, e := range entries { + row, ok := rows[e.graphPath] + if e.tombstone { + ok = !ok + } else { + ok = ok && row.ContentHash == e.hash + } + if ok { + held = append(held, e) + } + } + return held +} diff --git a/internal/mcp/pending_projection_cohort.go b/internal/mcp/pending_projection_cohort.go new file mode 100644 index 000000000..e28dd4624 --- /dev/null +++ b/internal/mcp/pending_projection_cohort.go @@ -0,0 +1,79 @@ +package mcp + +import ( + "errors" + "maps" +) + +// Every member was prepared and verified outside the registry mutex while its +// original source frontier was held. Expected is the exact old COW pointer; +// nil means that no entry existed. This is cache admission, never disk or +// journal authority. A refusal changes none of the registry. +type projectionCohortMember struct { + Entry *projectionEntry + Expected *projectionEntry +} + +func (p *pendingProjections) publishCohort(members []projectionCohortMember) error { + if len(members) == 0 { + return nil + } + if len(members) > projectionMaxFiles { + return projectionOverFiles + } + if !p.enabled { + return projectionDisabled + } + p.mu.Lock() + defer p.mu.Unlock() + current := p.snap.Load() + bytes := current.bytes + files := len(current.entries) + seen := make(map[string]struct{}, len(members)) + refuse := func(reason projectionRefusal) error { p.refused[reason]++; return reason } + for _, member := range members { + entry := member.Entry + if entry == nil || entry.key == "" || entry.graphPath == "" || entry.size < 0 { + return errors.New("projection cohort contains invalid entry") + } + if _, duplicate := seen[entry.key]; duplicate { + return errors.New("projection cohort contains duplicate physical path") + } + seen[entry.key] = struct{}{} + prior := current.entries[entry.key] + if prior != member.Expected { + return refuse(projectionSuperseded) + } + if prior != nil { + if prior.graphPath != entry.graphPath { + return refuse(projectionPathCase) + } + if prior.commitSeq > entry.commitSeq { + return refuse(projectionSuperseded) + } + bytes -= prior.size + } else { + files++ + } + if entry.size > projectionMaxBytes { + return refuse(projectionOverBytes) + } + bytes += entry.size + } + if bytes > projectionMaxBytes { + return refuse(projectionOverBytes) + } + if files > projectionMaxFiles { + return refuse(projectionOverFiles) + } + next := &projectionSnapshot{entries: maps.Clone(current.entries), bytes: bytes} + for _, member := range members { + if member.Expected != nil && member.Expected.suspect.Load() { + member.Entry.suspect.Store(true) + } + next.entries[member.Entry.key] = member.Entry + } + p.snap.Store(next) + p.published.Add(int64(len(members))) + return nil +} diff --git a/internal/mcp/pending_projection_cohort_test.go b/internal/mcp/pending_projection_cohort_test.go new file mode 100644 index 000000000..15c7b5999 --- /dev/null +++ b/internal/mcp/pending_projection_cohort_test.go @@ -0,0 +1,80 @@ +package mcp + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestProjectionCohortRefusalLeavesEveryOldPointerAndBudget(t *testing.T) { + for _, failure := range []string{"bytes", "files", "stale pointer", "older sequence", "duplicate"} { + t.Run(failure, func(t *testing.T) { + registry := newPendingProjections() + registry.enabled = true + oldA := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 1, size: 10} + oldB := &projectionEntry{key: "b", graphPath: "b.go", commitSeq: 1, size: 10} + require.NoError(t, registry.publish(oldA)) + require.NoError(t, registry.publish(oldB)) + if failure == "files" { + for i := 2; i < projectionMaxFiles; i++ { + key := fmt.Sprintf("f%d", i) + require.NoError(t, registry.publish(&projectionEntry{key: key, graphPath: key, size: 1})) + } + } + first := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 2, size: 20} + second := &projectionEntry{key: "b", graphPath: "b.go", commitSeq: 2, size: 20} + members := []projectionCohortMember{{Entry: first, Expected: oldA}, {Entry: second, Expected: oldB}} + switch failure { + case "bytes": + second.size = projectionMaxBytes + case "files": + members = append(members, projectionCohortMember{Entry: &projectionEntry{key: "new", graphPath: "new.go", commitSeq: 2, size: 1}}) + case "stale pointer": + members[1].Expected = &projectionEntry{} + case "older sequence": + second.commitSeq = 0 + case "duplicate": + second.key = "a" + } + before := registry.snap.Load() + require.Error(t, registry.publishCohort(members)) + require.Same(t, before, registry.snap.Load(), "no member or byte accounting can advance on a cohort refusal") + require.Same(t, oldA, registry.snap.Load().entries["a"]) + require.Same(t, oldB, registry.snap.Load().entries["b"]) + }) + } +} + +func TestProjectionCohortPublishesOneCompleteSnapshotAndPreservesSuspect(t *testing.T) { + registry := newPendingProjections() + registry.enabled = true + old := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 1, size: 10} + old.suspect.Store(true) + require.NoError(t, registry.publish(old)) + before := registry.snap.Load() + first := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 2, size: 20} + second := &projectionEntry{key: "b", graphPath: "b.go", commitSeq: 2, size: 30} + require.NoError(t, registry.publishCohort([]projectionCohortMember{{Entry: first, Expected: old}, {Entry: second}})) + after := registry.snap.Load() + require.NotSame(t, before, after) + require.EqualValues(t, 50, after.bytes) + require.Same(t, first, after.entries["a"]) + require.Same(t, second, after.entries["b"]) + require.True(t, first.suspect.Load()) + require.Len(t, before.entries, 1) + require.Same(t, old, before.entries["a"]) +} + +func TestProjectionCohortBudgetUsesFinalCombinedReplacement(t *testing.T) { + registry := newPendingProjections() + registry.enabled = true + oldA := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 1, size: projectionMaxBytes - 20} + oldB := &projectionEntry{key: "b", graphPath: "b.go", commitSeq: 1, size: 20} + require.NoError(t, registry.publish(oldA)) + require.NoError(t, registry.publish(oldB)) + nextA := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 2, size: projectionMaxBytes - 10} + nextB := &projectionEntry{key: "b", graphPath: "b.go", commitSeq: 2, size: 10} + require.NoError(t, registry.publishCohort([]projectionCohortMember{{Entry: nextA, Expected: oldA}, {Entry: nextB, Expected: oldB}})) + require.EqualValues(t, projectionMaxBytes, registry.snap.Load().bytes) +} diff --git a/internal/mcp/pending_projection_retire_paths_test.go b/internal/mcp/pending_projection_retire_paths_test.go new file mode 100644 index 000000000..f20e93c8c --- /dev/null +++ b/internal/mcp/pending_projection_retire_paths_test.go @@ -0,0 +1,42 @@ +package mcp + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPendingProjectionRetirementDoesNotCrossPhysicalCheckouts(t *testing.T) { + a, b := newProjectionFixture(t), newProjectionFixture(t) + p := newPendingProjections() + const current = "package p\n\nfunc New() {}\n" + ea, eb := a.edit("a.go", current), b.edit("a.go", current) + require.Equal(t, ea.graphPath, eb.graphPath) + require.Equal(t, ea.repoPrefix, eb.repoPrefix) + require.Equal(t, ea.hash, eb.hash, "equal graph paths and byte identities cannot select a physical checkout") + require.NotEqual(t, ea.key, eb.key) + require.NoError(t, p.publish(ea)) + require.NoError(t, p.publish(eb)) + require.NoError(t, a.idx.IndexFile(filepath.Join(a.dir, "a.go"))) + require.NotNil(t, b.g.GetNode("a.go::Old"), "checkout B's backend has not caught up") + assert.Equal(t, 1, p.retirePaths(a.g, []string{ea.absPath}), "A publication may verify only A's physical frontier") + require.Nil(t, liveEntry(p, ea.absPath)) + require.Same(t, eb, liveEntry(p, eb.absPath), "A's matching store hash must not retire B's still-pending graph") + require.NoError(t, b.idx.IndexFile(filepath.Join(b.dir, "a.go"))) + require.Equal(t, 1, p.retirePaths(b.g, []string{eb.absPath})) + require.Nil(t, liveEntry(p, eb.absPath)) +} + +func TestPendingProjectionScopedRetirementEmptySetDoesNoStoreWork(t *testing.T) { + f := newProjectionFixture(t) + p := newPendingProjections() + e := f.edit("a.go", "package p\n\nfunc New() {}\n") + require.NoError(t, p.publish(e)) + store := &projectionRetirementSpy{Graph: f.g} + require.Zero(t, p.retirePaths(store, nil)) + require.Zero(t, store.metadataReads) + require.Zero(t, store.nodeReads) + require.Same(t, e, liveEntry(p, e.absPath)) +} diff --git a/internal/mcp/pending_projection_stamp_test.go b/internal/mcp/pending_projection_stamp_test.go new file mode 100644 index 000000000..634a0fd03 --- /dev/null +++ b/internal/mcp/pending_projection_stamp_test.go @@ -0,0 +1,68 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDiskStampKeepsIdentityBeforeReplacement(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.go") + const content = "package example\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + before, err := statDiskStamp(path) + require.NoError(t, err) + // Allocate the replacement before unlinking the original so its file ID + // cannot be a reuse of the original's ID. Do not compare before the rename: + // that would accidentally initialize a lazy Windows FileInfo identity. + replacement := path + ".tmp" + require.NoError(t, os.WriteFile(replacement, []byte(content), 0o600)) + mtime := before.info.ModTime() + require.NoError(t, os.Chtimes(replacement, mtime, mtime)) + require.NoError(t, os.Rename(replacement, path)) + after, err := statDiskStamp(path) + require.NoError(t, err) + require.Equal(t, before.info.Size(), after.info.Size()) + require.True(t, before.info.ModTime().Equal(after.info.ModTime())) + require.False(t, before.matches(after), "replacement must invalidate a stamp even before its first comparison") +} + +func TestDiskStampKeepsIdentityAfterOriginalPathDisappears(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.go") + alias := path + ".link" + require.NoError(t, os.WriteFile(path, []byte("package example\n"), 0o600)) + if err := os.Link(path, alias); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + before, err := statDiskStamp(path) + require.NoError(t, err) + require.NoError(t, os.Remove(path)) + after, err := statDiskStamp(alias) + require.NoError(t, err) + require.True(t, before.matches(after), "identity belongs to the stamped object, not a later lookup of its old path") +} + +func TestDiskStampPreservesLeafType(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file.go") + require.NoError(t, os.WriteFile(path, []byte("package example\n"), 0o600)) + for _, path := range []string{path, dir} { + stamp, err := statDiskStamp(path) + require.NoError(t, err) + info, err := os.Lstat(path) + require.NoError(t, err) + require.Equal(t, info.Mode().Type(), stamp.info.Mode().Type()) + } + missing, err := statDiskStamp(filepath.Join(dir, "absent.go")) + require.NoError(t, err) + require.Nil(t, missing.info) + link := filepath.Join(dir, "symlink.go") + if err := os.Symlink(path, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + stamp, err := statDiskStamp(link) + require.NoError(t, err) + require.NotZero(t, stamp.info.Mode()&os.ModeSymlink, "a disk stamp must not follow the leaf") +} diff --git a/internal/mcp/pending_projection_stamp_unix_test.go b/internal/mcp/pending_projection_stamp_unix_test.go new file mode 100644 index 000000000..a351c9d99 --- /dev/null +++ b/internal/mcp/pending_projection_stamp_unix_test.go @@ -0,0 +1,20 @@ +//go:build unix + +package mcp + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func TestDiskStampDoesNotOpenFIFO(t *testing.T) { + path := filepath.Join(t.TempDir(), "pipe") + require.NoError(t, unix.Mkfifo(path, 0o600)) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + require.NotZero(t, stamp.info.Mode()&os.ModeNamedPipe) +} diff --git a/internal/mcp/pending_projection_stamp_windows_test.go b/internal/mcp/pending_projection_stamp_windows_test.go new file mode 100644 index 000000000..340b223ad --- /dev/null +++ b/internal/mcp/pending_projection_stamp_windows_test.go @@ -0,0 +1,26 @@ +//go:build windows + +package mcp + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDiskStampFailsClosedWhenWindowsIdentityCannotBeLoaded(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.go") + require.NoError(t, os.WriteFile(path, []byte("package example\n"), 0o600)) + info, err := os.Lstat(path) + require.NoError(t, err) + // Exercise the real race between Lstat metadata and eager ID capture. + // Go 1.27.3 retains the path for lazy identity loading; removing that + // path makes the ID unavailable without relying on sharing semantics. + // Do not call SameFile before removal, which would initialize the ID. + require.NoError(t, os.Remove(path)) + stamp, err := freezeDiskStampIdentity(path, info) + require.ErrorContains(t, err, "file identity unavailable") + require.Nil(t, stamp.info, "metadata without a captured identity cannot become a trusted stamp") +} diff --git a/internal/mcp/pending_projection_test.go b/internal/mcp/pending_projection_test.go new file mode 100644 index 000000000..13799e207 --- /dev/null +++ b/internal/mcp/pending_projection_test.go @@ -0,0 +1,437 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/pathkey" +) + +type projectionFixture struct { + t *testing.T + dir string + g *graph.Graph + idx *indexer.Indexer + seq uint64 +} + +// newProjectionFixture indexes a one-file Go repository into an in-memory +// store, the state before a tool edit. +func newProjectionFixture(t *testing.T) *projectionFixture { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), []byte("package p\n\nfunc Old() {}\n"), 0o644)) + g := graph.New() + idx := newProjectionTestIndexer(t, g) + _, err := idx.Index(dir) + require.NoError(t, err) + return &projectionFixture{t: t, dir: dir, g: g, idx: idx} +} + +func newProjectionTestIndexer(t testing.TB, g graph.Store) *indexer.Indexer { + t.Helper() + registry := parser.NewRegistry() + languages.RegisterAll(registry) + cfg := config.Default().Index + cfg.Workers = 1 + idx := indexer.New(g, registry, cfg, zap.NewNop()) + t.Cleanup(idx.Close) + return idx +} + +// edit writes content to rel as a tool would and projects it. +func (f *projectionFixture) edit(rel, content string) *projectionEntry { + f.t.Helper() + path := filepath.Join(f.dir, rel) + require.NoError(f.t, os.WriteFile(path, []byte(content), 0o644)) + stamp, err := statDiskStamp(path) + require.NoError(f.t, err) + f.seq++ + e, err := projectFile(context.Background(), f.idx, path, []byte(content), f.seq, stamp) + require.NoError(f.t, err) + return e +} + +func liveEntry(p *pendingProjections, path string) *projectionEntry { + return p.snap.Load().entries[pathkey.MutationKey(path)] +} + +type projectionRetirementSpy struct { + *graph.Graph + metadataReads int + nodeReads int +} + +func (s *projectionRetirementSpy) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { + s.metadataReads++ + return s.Graph.FileMetasByPaths(prefix, paths) +} + +func (s *projectionRetirementSpy) GetFileNodes(path string) []*graph.Node { + s.nodeReads++ + return s.Graph.GetFileNodes(path) +} + +func TestPendingProjectionRetirementUsesBackendContentEvidence(t *testing.T) { + f := newProjectionFixture(t) + p := newPendingProjections() + e := f.edit("a.go", "package p\n\nfunc New() {}\n") + require.NoError(t, p.publish(e)) + store := &projectionRetirementSpy{Graph: f.g} + wrapped := newContractCoreEdges(store, t.Context(), nil) + require.NotImplements(t, (*graph.FileMetaPathReader)(nil), wrapped, + "durable retirement evidence is backend state, not a request-view capability") + require.Zero(t, p.retire(store), "old durable bytes cannot retire a newer projection") + require.Equal(t, 1, store.metadataReads) + require.Zero(t, store.nodeReads, "mismatched hashes need no node comparison") + + require.NoError(t, f.idx.IndexFile(filepath.Join(f.dir, "a.go"))) + require.Equal(t, 1, p.retire(store)) + require.Equal(t, 3, store.metadataReads, "retirement brackets its node comparison with durable hashes") + require.Equal(t, 1, store.nodeReads) + require.Zero(t, p.divergence.Load()) + require.Nil(t, liveEntry(p, e.absPath)) +} + +func TestPendingProjectionPublishRetire(t *testing.T) { + f := newProjectionFixture(t) + p := newPendingProjections() + require.Nil(t, pendingProjectionHealth(p)) + path := filepath.Join(f.dir, "a.go") + + first := f.edit("a.go", "package p\n\nfunc New() {}\n") + require.NoError(t, p.publish(first)) + view := graph.NewOverlaidView(f.g, first.layer) + require.Nil(t, view.GetNode("a.go::Old")) + require.NotNil(t, view.GetNode("a.go::New")) + + t.Run("a newer entry replaces the older at once", func(t *testing.T) { + second := f.edit("a.go", "package p\n\nfunc Newer() {}\n") + require.NoError(t, p.publish(second)) + require.Same(t, second, liveEntry(p, path)) + require.ErrorIs(t, p.publish(first), projectionSuperseded) + require.Same(t, second, liveEntry(p, path)) + }) + + t.Run("a failed patch keeps the entry", func(t *testing.T) { + require.Zero(t, p.retire(f.g), "the store still holds the old bytes") + require.NotNil(t, liveEntry(p, path)) + }) + + t.Run("the entry retires once the store holds its bytes", func(t *testing.T) { + require.NoError(t, f.idx.IndexFile(path)) + require.Equal(t, 1, p.retire(f.g)) + require.Nil(t, liveEntry(p, path)) + require.Zero(t, p.divergence.Load()) + }) + + t.Run("a retired entry that disagrees with the store counts as divergence", func(t *testing.T) { + e := f.edit("a.go", "package p\n\nfunc Newest() {}\n") + require.NoError(t, f.idx.IndexFile(path)) + e.layer.AddNode(e.graphPath, &graph.Node{ID: "a.go::Phantom", Kind: graph.KindFunction, FilePath: "a.go"}) + require.NoError(t, p.publish(e)) + require.Equal(t, 1, p.retire(f.g)) + require.EqualValues(t, 1, p.divergence.Load()) + }) + + t.Run("a tombstone hides the file's symbols until the store drops the file", func(t *testing.T) { + require.NoError(t, os.Remove(path)) + f.seq++ + tomb := newProjectionTombstone(path, "a.go", "", f.seq) + require.NoError(t, p.publish(tomb)) + e, err := p.lookup(path) + require.NoError(t, err) + view := graph.NewOverlaidView(f.g, e.layer) + require.NotEmpty(t, f.g.GetFileNodes("a.go")) + require.Empty(t, view.GetFileNodes("a.go")) + require.Empty(t, view.FindNodesByName("Newest")) + require.Nil(t, view.GetNode("a.go::Newest")) + + require.Zero(t, p.retire(f.g)) + require.NoError(t, f.g.DeleteFileMetasByFiles("", []string{"a.go"})) + f.g.EvictFile("a.go") + require.Equal(t, 1, p.retire(f.g)) + }) + + t.Run("a case variant of a live entry is refused", func(t *testing.T) { + prev := pathkey.CaseInsensitivePaths + pathkey.CaseInsensitivePaths = true + t.Cleanup(func() { pathkey.CaseInsensitivePaths = prev }) + live := f.edit("b.go", "package p\n\nfunc B() {}\n") + require.NoError(t, p.publish(live)) + variant := newProjectionEntry(filepath.Join(f.dir, "B.go"), "", &indexer.Projection{GraphPath: "B.go"}, nil, f.seq+1, diskStamp{}) + require.ErrorIs(t, p.publish(variant), projectionPathCase) + require.Same(t, live, liveEntry(p, filepath.Join(f.dir, "b.go"))) + }) + + health := pendingProjectionHealth(p) + require.Equal(t, 1, health["files"]) + require.EqualValues(t, 3, health["retired"]) + require.EqualValues(t, 1, health["projection_divergence"]) + require.Equal(t, map[string]int64{"superseded": 1, "path_case": 1}, health["refused"]) +} + +func TestPendingProjectionLookupChecksDisk(t *testing.T) { + f := newProjectionFixture(t) + path := filepath.Join(f.dir, "a.go") + const content = "package p\n\nfunc Edited() {}\n" + sameSize := "package p\n\nfunc Changed() {}\n"[:len(content)] + + for _, tc := range []struct { + name string + suspect bool + change func(t *testing.T, mtime time.Time) + served bool + }{ + {name: "unchanged", served: true}, + {name: "unchanged but suspect", suspect: true, served: true}, + {name: "size", change: func(t *testing.T, _ time.Time) { + require.NoError(t, os.WriteFile(path, []byte(content+"// more\n"), 0o644)) + }}, + {name: "mtime", change: func(t *testing.T, mtime time.Time) { + require.NoError(t, os.Chtimes(path, mtime, mtime.Add(time.Second))) + }}, + {name: "inode", change: func(t *testing.T, mtime time.Time) { + replacement := path + ".tmp" + require.NoError(t, os.WriteFile(replacement, []byte(content), 0o644)) + require.NoError(t, os.Chtimes(replacement, mtime, mtime)) + require.NoError(t, os.Rename(replacement, path)) + }}, + {name: "same stat, other bytes, after a watcher event", suspect: true, change: func(t *testing.T, mtime time.Time) { + require.NoError(t, os.WriteFile(path, []byte(sameSize), 0o644)) + require.NoError(t, os.Chtimes(path, mtime, mtime)) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + p := newPendingProjections() + e := f.edit("a.go", content) + require.NoError(t, p.publish(e)) + if tc.change != nil { + tc.change(t, e.disk.info.ModTime()) + } + if tc.suspect { + p.markSuspect(path) + } + got, err := p.lookup(path) + if tc.served { + require.NoError(t, err) + require.Same(t, e, got) + return + } + require.ErrorIs(t, err, errProjectionExternalWrite) + require.Nil(t, got) + require.Nil(t, liveEntry(p, path), "a stale entry must not be served again") + require.EqualValues(t, 1, p.externalWrites.Load()) + }) + } +} + +func syntheticProjectionEntry(path, graphPath, hash string, seq uint64, stamp diskStamp, raw []byte) *projectionEntry { + node := &graph.Node{ID: graphPath, Kind: graph.KindFile, FilePath: graphPath, StartLine: 1, EndLine: 1} + projection := &indexer.Projection{GraphPath: graphPath, Language: "go", ContentHash: hash, Nodes: []*graph.Node{node}} + return newProjectionEntry(path, "", projection, raw, seq, stamp) +} + +func TestPendingProjectionCapsRefuseWithoutEviction(t *testing.T) { + dir := t.TempDir() + p := newPendingProjections() + for i := range projectionMaxFiles { + name := fmt.Sprintf("f%03d.go", i) + require.NoError(t, p.publish(syntheticProjectionEntry(filepath.Join(dir, name), name, "h", 1, diskStamp{}, nil))) + } + over := syntheticProjectionEntry(filepath.Join(dir, "over.go"), "over.go", "h", 1, diskStamp{}, nil) + require.ErrorIs(t, p.publish(over), projectionOverFiles) + require.NoError(t, p.publish(syntheticProjectionEntry(filepath.Join(dir, "f000.go"), "f000.go", "h", 2, diskStamp{}, nil)), + "replacing a live file at the cap is not growth") + + huge := syntheticProjectionEntry(filepath.Join(dir, "f001.go"), "f001.go", "h", 2, diskStamp{}, nil) + huge.size = projectionMaxBytes + require.ErrorIs(t, p.publish(huge), projectionOverBytes) + + snap := p.snap.Load() + require.Len(t, snap.entries, projectionMaxFiles) + require.NotSame(t, huge, liveEntry(p, filepath.Join(dir, "f001.go"))) + require.Nil(t, liveEntry(p, filepath.Join(dir, "over.go"))) +} + +func TestPendingProjectionKillSwitch(t *testing.T) { + t.Setenv("GORTEX_PROJECTION", "0") + p := newPendingProjections() + path := filepath.Join(t.TempDir(), "a.go") + require.ErrorIs(t, p.publish(syntheticProjectionEntry(path, "a.go", "h", 1, diskStamp{}, nil)), projectionDisabled) + require.Empty(t, p.snap.Load().entries) + e, err := p.lookup(path) + require.NoError(t, err) + require.Nil(t, e) +} + +// storeTripwire forwards to a real store until it is cleared; any store call +// after that panics. +type storeTripwire struct{ graph.Store } + +func TestProjectFileReadsNoStore(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "a.go") + content := []byte("package p\n\nimport \"fmt\"\n\nfunc F() { fmt.Println(G()) }\n\nfunc G() int { return 1 }\n") + require.NoError(t, os.WriteFile(path, content, 0o644)) + store := &storeTripwire{Store: graph.New()} + idx := newProjectionTestIndexer(t, store) + idx.SetRootPath(dir) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + p := newPendingProjections() + + backing := store.Store + store.Store = nil + func() { + defer func() { + store.Store = backing + if r := recover(); r != nil { + t.Fatalf("store accessed while building or publishing a projection: %v", r) + } + }() + e, err := projectFile(context.Background(), idx, path, content, 1, stamp) + require.NoError(t, err) + require.NoError(t, p.publish(e)) + got, err := p.lookup(path) + require.NoError(t, err) + require.Same(t, e, got) + require.NotEmpty(t, got.layer.NodesByName("G")) + }() +} + +// 64 goroutines publish, retire, mark and read at once. Readers never see a +// torn snapshot, and retirement never drops an entry a newer publish put in +// place: every file ends with its newest entry. +func TestPendingProjectionConcurrentPublishRetireRead(t *testing.T) { + const ( + files = 16 + publishers = 32 + rounds = 50 + retirers = 16 + readers = 16 + ) + dir := t.TempDir() + store := graph.New() + paths := make([]string, files) + stamps := make([]diskStamp, files) + raw := []byte("package p\n") + for i := range files { + name := fmt.Sprintf("f%02d.go", i) + paths[i] = filepath.Join(dir, name) + require.NoError(t, os.WriteFile(paths[i], raw, 0o644)) + stamp, err := statDiskStamp(paths[i]) + require.NoError(t, err) + stamps[i] = stamp + store.AddNode(&graph.Node{ID: name, Kind: graph.KindFile, FilePath: name, StartLine: 1, EndLine: 1}) + require.NoError(t, store.SetFileMetas("", []graph.FileMetaRow{{FilePath: name, ContentHash: "stale"}})) + } + + p := newPendingProjections() + var seq atomic.Uint64 + last := make([]atomic.Uint64, publishers) + var publishing, background sync.WaitGroup + done := make(chan struct{}) + for w := range publishers { + publishing.Add(1) + go func() { + defer publishing.Done() + file := w % files + for r := range rounds { + s := seq.Add(1) + // Only the last round's bytes are newer than the store's, so + // that entry must survive retirement. + hash := "stale" + if r == rounds-1 { + hash = "fresh" + last[w].Store(s) + } + e := syntheticProjectionEntry(paths[file], filepath.Base(paths[file]), hash, s, stamps[file], raw) + if err := p.publish(e); err != nil && !errors.Is(err, projectionSuperseded) { + t.Errorf("publish: %v", err) + } + } + }() + } + for range retirers { + background.Add(1) + go func() { + defer background.Done() + for { + select { + case <-done: + return + default: + p.retire(store) + } + } + }() + } + for r := range readers { + background.Add(1) + go func() { + defer background.Done() + for i := 0; ; i++ { + select { + case <-done: + return + default: + } + path := paths[(r+i)%files] + if i%3 == 0 { + p.markSuspect(path) + } + e, err := p.lookup(path) + if err != nil { + t.Errorf("lookup %s: %v", path, err) + return + } + if e != nil && (e.key != pathkey.MutationKey(path) || len(e.layer.FileNodes(e.graphPath)) != 1) { + t.Errorf("lookup %s served a torn entry", path) + return + } + snap := p.snap.Load() + var bytes int64 + for _, e := range snap.entries { + bytes += e.size + } + if bytes != snap.bytes { + t.Errorf("snapshot bytes %d, entries sum to %d", snap.bytes, bytes) + return + } + } + }() + } + publishing.Wait() + close(done) + background.Wait() + p.retire(store) + + for file, path := range paths { + var newest uint64 + for w := range publishers { + if w%files == file { + newest = max(newest, last[w].Load()) + } + } + e := liveEntry(p, path) + require.NotNil(t, e, "entry for %s was lost", path) + require.Equal(t, newest, e.commitSeq, path) + require.Equal(t, "fresh", e.hash, path) + } +} diff --git a/internal/mcp/primary_publication_write_intent_test.go b/internal/mcp/primary_publication_write_intent_test.go index 5b60c593e..5b1755e62 100644 --- a/internal/mcp/primary_publication_write_intent_test.go +++ b/internal/mcp/primary_publication_write_intent_test.go @@ -10,9 +10,11 @@ import ( "testing" "time" + mcplib "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/graphview" "github.com/zzet/gortex/internal/indexer" ) @@ -211,3 +213,35 @@ func TestPrimaryMutationFallbackKeepsOrdinaryWriteIntent(t *testing.T) { }) } } + +// TestPrimaryEditAnnouncesNoWriteIntent spies on the store across a primary +// checkout edit_file in watcher mode. The edit writes only the source file +// and the watcher's patch announces its own store writes, so nothing on the +// ack path may announce a writer that holds off WAL reclaim for the request. +func TestPrimaryEditAnnouncesNoWriteIntent(t *testing.T) { + t.Setenv("GORTEX_SQLITE_LAZY_INDEXES", "off") + t.Setenv("GORTEX_SQLITE_WAL_RECLAIM_MB", "0") + store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "intent.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + srv, dir := setupTestServer(t) + srv.materializer = &graphview.Materializer{Store: store} + require.True(t, srv.facades.mutatesSource("edit_file")) + mainGo := filepath.Join(dir, "main.go") + done := make(chan indexer.MutationResult, 1) + done <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + close(done) + w := &publicationIntentWatcher{watcherHistory: mutationTestWatcher{}, store: store, + tickets: map[string]*indexer.MutationTicket{mainGo: {Path: mainGo, Generation: 1, Done: done}}} + srv.SetWatcher(w) + + ctx, release := srv.withSourceMutationWriteIntent(t.Context(), "edit_file") + defer release() + require.False(t, store.WriteWanted(), "the edit request announced a store writer") + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{"path": mainGo, "old_string": "func helper() {}", "new_string": "func helper() { _ = 1 }"} + res, err := srv.handleEditFile(ctx, req) + require.NoError(t, err) + require.False(t, res.IsError, "edit refused: %v", res.Content) + require.False(t, w.protected.Load(), "watcher admission ran under an announced store writer") +} diff --git a/internal/mcp/read_repair_observation_test.go b/internal/mcp/read_repair_observation_test.go index b3cea416b..3128fe2fa 100644 --- a/internal/mcp/read_repair_observation_test.go +++ b/internal/mcp/read_repair_observation_test.go @@ -108,7 +108,7 @@ func testReadRepairBeforeWatcherAdmission(t *testing.T, inFlight, earlyExecution } } } - record, err := s.commitFileMutation(ctx, "write_file", "logical-reader-admission", "reader admission fixture", "main.go", path, after, 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "logical-reader-admission", "reader admission fixture", "main.go", path, after, 0o600) require.NoError(t, err) require.Nil(t, record.publication) require.Nil(t, record.readRepairAdmission.Value()) @@ -243,7 +243,7 @@ func TestReadRepairSeesCommittedAdmissionBeforeACKAndSurvivesSuccessClear(t *tes ctx, cancel := context.WithCancel(canonicalTimingContext()) t.Cleanup(cancel) path := filepath.Join(t.TempDir(), "edit.go") - record, err := s.commitFileMutation(ctx, "write_file", "caller-logical-id", "distinct-id fixture", filepath.Base(path), path, []byte("package edited\n"), 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "caller-logical-id", "distinct-id fixture", filepath.Base(path), path, []byte("package edited\n"), 0o600) require.NoError(t, err) idx := &indexer.Indexer{} outcome := make(chan mutationReindexOutcome, 1) diff --git a/internal/mcp/read_repair_straddling_commit_test.go b/internal/mcp/read_repair_straddling_commit_test.go index 92b462419..366f13048 100644 --- a/internal/mcp/read_repair_straddling_commit_test.go +++ b/internal/mcp/read_repair_straddling_commit_test.go @@ -98,7 +98,7 @@ func TestReadRepairActualExecutorStraddlesCommittedBookkeeping(t *testing.T) { t.Fatal("actual executor did not enter its first phase before commit") } } - record, err := s.commitFileMutation(ctx, "write_file", "logical-straddling", "straddling fixture", "main.go", path, after, 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "logical-straddling", "straddling fixture", "main.go", path, after, 0o600) require.NoError(t, err) waiterDone := make(chan mutationReindexOutcome, 1) go func() { waiterDone <- s.mutationReindexState(ctx, path) }() diff --git a/internal/mcp/relation_freshness.go b/internal/mcp/relation_freshness.go index b586cf68b..e97072bc0 100644 --- a/internal/mcp/relation_freshness.go +++ b/internal/mcp/relation_freshness.go @@ -4,13 +4,13 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "os" "sort" "strings" "time" - wire "github.com/gortexhq/gcx-go" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/graph" @@ -27,7 +27,8 @@ const maxLabelledPendingFiles = 16 // relationFreshness labels an answer read from graph relations while edits in // its scope are still being published. The zero value is a fresh scope. type relationFreshness struct { - state string // "", "pending" or "failed" + scope string + state string // Empty is fresh internally; explicit responses may render fresh or unknown. files []string // graph-keyed paths of the lagging in-scope edits, sorted waited time.Duration } @@ -58,78 +59,49 @@ func relationWaitDeadline(ctx context.Context, freshness requestFreshness) time. // the scope are neither waited on nor reported. func (s *Server) awaitRelationFreshness(ctx context.Context, ids, files []string, deadline time.Time) relationFreshness { started := time.Now() - lagging := s.laggingMutationReceipts(ctx) - if len(lagging) == 0 { - return relationFreshness{waited: time.Since(started)} + label := s.freshnessFor(ctx, "relations", ids, files) + if label.state == "" || label.state == "failed" || !deadline.After(started) { + label.waited = time.Since(started) + return label } - inScope := s.relationScope(ctx, ids, files) - if remaining := time.Until(deadline); remaining > 0 { - timer := time.NewTimer(remaining) - defer timer.Stop() - wait: - for _, receipt := range lagging { - if !inScope(receipt.path) { + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + for { + select { + case <-ticker.C: + label = s.freshnessFor(ctx, "relations", ids, files) + if label.state != "" && label.state != "failed" { continue } - select { - case <-receipt.done: - case <-timer.C: - break wait - case <-ctx.Done(): - break wait - } - } - lagging = s.laggingMutationReceipts(ctx) - } - label := s.relationLabel(lagging, inScope) - label.waited = time.Since(started) - return label -} - -// labelRelations labels an answer that read relations without waiting. gaps -// is the laggingMutationReceipts snapshot taken before the walk; the scope of -// ids and files, known only once the walk has run, filters it afterwards. An -// edit that published during the walk still labels the answer, which may -// have read around it. -func (s *Server) labelRelations(ctx context.Context, gaps []*mutationReceipt, ids, files []string) relationFreshness { - if len(gaps) == 0 { - return relationFreshness{} - } - return s.relationLabel(gaps, s.relationScope(ctx, ids, files)) -} - -// relationLabel labels the receipts that fall in scope: failed when the edit -// failed to publish, pending otherwise. One pending receipt for a path -// outweighs an older failed one, and any failed path makes the label failed. -func (s *Server) relationLabel(receipts []*mutationReceipt, inScope func(absPath string) bool) relationFreshness { - states := make(map[string]string) - for _, receipt := range receipts { - if !inScope(receipt.path) { - continue - } - key := s.repoRelative(receipt.path) - outcome := receipt.outcome(true) - if outcome.Pending || (outcome.Err == nil && outcome.Reindexed) { - states[key] = "pending" - } else if states[key] == "" { - states[key] = "failed" - } - } - var label relationFreshness - for path, state := range states { - label.files = append(label.files, path) - if label.state != "failed" { - label.state = state + case <-timer.C: + label = s.freshnessFor(ctx, "relations", ids, files) + case <-ctx.Done(): } + label.waited = time.Since(started) + return label } - sort.Strings(label.files) - return label } // laggingMutationReceipts returns the request's mutation receipts whose edit // the graph has not published: still pending, or terminally failed. func (s *Server) laggingMutationReceipts(ctx context.Context) []*mutationReceipt { var lagging []*mutationReceipt + if paths, watched := s.canonicalTierPaths(ctx); watched { + for path, tiers := range paths { + if !tiers.Failed && !tiers.RelationsAt.IsZero() { + continue + } + receipt := &mutationReceipt{path: path, generation: tiers.Generation} + if tiers.Failed { + receipt.completed = true + receipt.result.Err = errors.New("file publication failed") + } + lagging = append(lagging, receipt) + } + return lagging + } for _, receipt := range s.mutationReceiptsForRepos(ctx) { if outcome := receipt.outcome(true); outcome.Pending || outcome.Err != nil || !outcome.Reindexed { lagging = append(lagging, receipt) @@ -147,7 +119,7 @@ func (s *Server) laggingMutationReceipts(ctx context.Context) []*mutationReceipt func (s *Server) relationScope(ctx context.Context, ids, files []string) func(absPath string) bool { scoped := make(map[string]bool, len(files)+len(ids)) for _, file := range files { - scoped[file] = true + scoped[s.graphRelPath(ctx, file)] = true } var names []string reader := s.readerFor(ctx) @@ -220,44 +192,13 @@ func namesAnyIdentifier(data []byte, names []string) bool { return false } -// apply writes the label into an answer's fields. A fresh scope writes -// nothing, so an answer with nothing lagging keeps its shape. -func (f relationFreshness) apply(result map[string]any) { - if f.state == "" { - return - } - result["complete"] = false - result["graph_freshness"] = f.state - result["freshness_scope"] = "relations" - result["pending_file_count"] = len(f.files) - result["pending_files"] = f.files[:min(maxLabelledPendingFiles, len(f.files))] - if f.state == "failed" { - result["recovery_hint"] = mutationFreshnessRecoveryHint() - } -} - -// labelled is payload with the label's fields merged in, a struct payload -// being re-read as its JSON object first. A fresh scope returns payload -// unchanged. -func (f relationFreshness) labelled(payload any) any { - if f.state == "" { - return payload - } - data, err := json.Marshal(payload) - if err != nil { - return payload - } - var fields map[string]any - if json.Unmarshal(data, &fields) != nil || fields == nil { - return payload - } - f.apply(fields) - return fields -} - // line is the relations line a text answer leads with. func (f relationFreshness) line() string { - return fmt.Sprintf("relations: %s (%d files)", f.state, len(f.files)) + scope := f.scope + if scope == "" { + scope = "relations" + } + return fmt.Sprintf("%s: %s (%d files)", scope, f.state, len(f.files)) } // requireFreshError is the answer to a require_fresh call whose wait ended @@ -274,6 +215,9 @@ func (f relationFreshness) requireFreshError() *mcp.CallToolResult { // merge folds the label of another scope the same answer read into f: the // files of both, failed over pending, and the time spent waiting on each. func (f relationFreshness) merge(other relationFreshness) relationFreshness { + if f.scope == "" { + f.scope = other.scope + } f.waited += other.waited if other.state == "" { return f @@ -294,31 +238,29 @@ func (f relationFreshness) merge(other relationFreshness) relationFreshness { return f } -// decorate makes a rendered text answer lead with its label: TOON and plain -// text start with the relations line; GCX keeps GCX1 as line 1, carries the -// label as header meta and the relations line as a comment on line 2. JSON -// already carries the label in its fields and passes through unchanged. -func (f relationFreshness) decorate(res *mcp.CallToolResult) *mcp.CallToolResult { - if f.state == "" || res == nil || res.IsError || len(res.Content) == 0 { - return res +// labelRelations keeps the pre-walk gaps as evidence even if they completed +// during the read, and also includes edits admitted while the walk ran. +func (s *Server) labelRelations(ctx context.Context, gaps []*mutationReceipt, ids, files []string) relationFreshness { + return s.freshnessFor(ctx, "relations", ids, files, gaps) +} + +// labelled is payload with the label's fields merged in, a struct payload +// being re-read as its JSON object first. A fresh scope returns payload +// unchanged. +func (f relationFreshness) labelled(payload any) any { + if f.state == "" { + return payload } - text, ok := res.Content[0].(mcp.TextContent) - if !ok || strings.HasPrefix(strings.TrimSpace(text.Text), "{") { - return res + data, err := json.Marshal(payload) + if err != nil { + return payload } - body := text.Text - if strings.HasPrefix(body, wire.Tag) { - body, _ = injectGCXHeaderMeta(body, map[string]any{ - "graph_freshness": f.state, - "freshness_scope": "relations", - "pending_file_count": len(f.files), - }) - head, rows, _ := strings.Cut(body, wire.RowSep) - body = head + wire.RowSep + wire.CommentPrefix + " " + f.line() + wire.RowSep + rows - } else { - body = f.line() + "\n" + body + var fields map[string]any + if json.Unmarshal(data, &fields) != nil || fields == nil { + return payload } - return rebuildTextResult(res, body) + f.apply(fields) + return fields } // provisionalSite is a line of a file whose edit the graph has not published diff --git a/internal/mcp/rename_apply.go b/internal/mcp/rename_apply.go index f9251e213..e28d5945d 100644 --- a/internal/mcp/rename_apply.go +++ b/internal/mcp/rename_apply.go @@ -320,14 +320,15 @@ func splitLinesKeepEnds(content string) []string { // renameFileWrite is one file's fully-computed rewrite, held in memory until // every affected file has passed verification. type renameFileWrite struct { - RelPath string - AbsPath string - Old []byte - New []byte - Edits int - NewSHA string - Gate parseGateResult - GateInfo map[string]any + RelPath string + AbsPath string + Old []byte + New []byte + Edits int + NewSHA string + Gate parseGateResult + GateInfo map[string]any + projection *preparedFileProjection } // planRenameWrites resolves every affected file, re-verifies each target line @@ -353,17 +354,17 @@ func (s *Server) planRenameWrites(ctx context.Context, edits []renameEdit, allow for _, relPath := range files { absPath, err := s.resolveGraphPath(ctx, relPath) if err != nil { - return nil, fmt.Errorf("could not resolve %s: %w", relPath, err) + return s.finishRenamePlans(ctx, writes, allowParseErrors, fmt.Errorf("could not resolve %s: %w", relPath, err)) } content, err := os.ReadFile(absPath) if err != nil { - return nil, fmt.Errorf("could not read %s: %w", relPath, err) + return s.finishRenamePlans(ctx, writes, allowParseErrors, fmt.Errorf("could not read %s: %w", relPath, err)) } // The rename rewrites graph lines into the raw bytes. On a UTF-16 // source that splices UTF-8 into NUL-interleaved text (#846); refuse // at plan time so even a dry run shows the encoding refusal. if indexer.LooksUTF16Source(content) { - return nil, refuseUTF16Edit("rename_symbol", relPath) + return s.finishRenamePlans(ctx, writes, allowParseErrors, refuseUTF16Edit("rename_symbol", relPath)) } lines := splitLinesKeepEnds(string(content)) @@ -371,18 +372,18 @@ func (s *Server) planRenameWrites(ctx context.Context, edits []renameEdit, allow sort.Slice(fileEdits, func(i, j int) bool { return fileEdits[i].Line < fileEdits[j].Line }) for _, e := range fileEdits { if e.Line < 1 || e.Line > len(lines) { - return nil, fmt.Errorf( + return s.finishRenamePlans(ctx, writes, allowParseErrors, fmt.Errorf( "%s:%d is outside the file (%d lines) — the index is stale relative to disk; re-run after reindexing", - relPath, e.Line, len(lines)) + relPath, e.Line, len(lines))) } body, term := splitLineTerminator(lines[e.Line-1]) if body != e.OldText { // The graph and disk disagree, so the planned replacement was // computed against content that is no longer there. Refuse // rather than write a line we never actually inspected. - return nil, fmt.Errorf( + return s.finishRenamePlans(ctx, writes, allowParseErrors, fmt.Errorf( "%s:%d changed on disk since it was planned — refusing the rename so no unreviewed line is rewritten. Re-run rename after the file settles", - relPath, e.Line) + relPath, e.Line)) } lines[e.Line-1] = e.NewText + term } @@ -396,25 +397,23 @@ func (s *Server) planRenameWrites(ctx context.Context, edits []renameEdit, allow Edits: len(fileEdits), NewSHA: gitBlobSHA(newContent), } - if parseGateEnabled() { - w.Gate = checkParseGate(relPath, content, newContent) - if w.Gate.Blocked && !allowParseErrors { - return nil, fmt.Errorf("%s", parseGateError(relPath, w.Gate)) - } - } - w.GateInfo = parseGateInfo(w.Gate, allowParseErrors) writes = append(writes, w) } - return writes, nil + return s.finishRenamePlans(ctx, writes, allowParseErrors, nil) } // commitRenameWrites writes each verified file atomically and reports the // per-file outcome. Callers must already hold the mutation locks for every -// path in writes. -func (s *Server) commitRenameWrites(ctx context.Context, writes []*renameFileWrite) []map[string]any { +// path in writes. The supplied release returns that entire set after admission +// and before any publication wait; callers also defer it for early failures. +func (s *Server) commitRenameWrites(ctx context.Context, writes []*renameFileWrite, releasePaths func()) []map[string]any { sess := s.sessionFor(ctx) results := make([]map[string]any, 0, len(writes)) - for i, w := range writes { + var targets []sourceMutationTarget + var records []*mutationCommitRecord + var projections []committedFileProjection + var applied []map[string]any + for _, w := range writes { entry := map[string]any{ "file": w.RelPath, "edits": w.Edits, @@ -432,33 +431,52 @@ func (s *Server) commitRenameWrites(ctx context.Context, writes []*renameFileWri // grinding through the rest: every remaining file would be refused // anyway, and the receipts already recorded say exactly how far the // rename got. - commit, err := s.commitFileMutation(ctx, "rename_symbol", "", "", w.RelPath, w.AbsPath, w.New, perm) + if w.projection == nil { + before := baselineForPreparedWrite(w.AbsPath, w.Old) + if before == nil { + before = captureFileViewBaseline(ctx, w.AbsPath) + } + if s.indexer == nil && s.multiIndexer == nil { + w.projection, _ = s.buildFileProjection(ctx, w.AbsPath, w.New) + } else { + s.noteAcknowledgementParser(ctx, "source_projection") + lap := s.beginAcknowledgementLap(ctx, editAckParseGate) + w.projection, _ = s.buildFileProjection(ctx, w.AbsPath, w.New) + lap.finish() + } + s.prepareFileViewEvidence(ctx, w.projection, before, w.New) + } + commit, err := s.commitFileMutation(ctx, "rename_symbol", "", "", w.RelPath, w.AbsPath, w.New, perm, sourceMutationOperation{kind: sourceCohortWrite, projection: w.projection}) if err != nil { entry["status"] = "failed" entry["error"] = err.Error() attachMutationCommit(entry, commit) results = append(results, entry) if errors.Is(err, errMutationNotApplied) { - return results + break } continue } entry["status"] = "applied" sess.recordModified(w.RelPath) - // Later files still write and may reindex inline, so only the last - // file's wait gives the dispatch slot back. - waitCtx := ctx - if i < len(writes)-1 { - waitCtx = withoutDispatchSlotRelease(ctx) - } - outcome := s.mutationReindexState(waitCtx, w.AbsPath) + targets = append(targets, captureSourceMutationTarget(ctx, w.AbsPath)) + records = append(records, commit) + projections = append(projections, committedFileProjection{record: commit, prepared: w.projection}) + applied = append(applied, entry) + results = append(results, entry) + } + s.publishCommittedProjectionCohort(ctx, projections) + admissions := s.admitSourceMutationFrontier(ctx, targets) + releasePaths() + outcomes := s.awaitSourceMutationFrontier(ctx, admissions) + for i, outcome := range outcomes { + entry, target, commit := applied[i], targets[i], records[i] commit.recordGraph(outcome) if outcome.Err != nil { entry["reindex_error"] = outcome.Err.Error() } - s.attachMutationFreshness(entry, w.RelPath, w.AbsPath, outcome) + s.attachMutationFreshness(entry, entry["file"].(string), target.path, outcome) attachMutationCommit(entry, commit) - results = append(results, entry) } return results } diff --git a/internal/mcp/rename_twenty_file_ack_acceptance_test.go b/internal/mcp/rename_twenty_file_ack_acceptance_test.go new file mode 100644 index 000000000..1c3c7df5b --- /dev/null +++ b/internal/mcp/rename_twenty_file_ack_acceptance_test.go @@ -0,0 +1,210 @@ +//go:build acceptance + +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/query" +) + +// The micro fixture owns exactly twenty affected Go files and a real watcher. +// It is not the SQLite/reference-host/transport acceptance workload. +type renameTwentyFixture struct { + server *Server + root string + original map[string]string + invoke mcpserver.ToolHandlerFunc +} + +func newRenameTwentyFixture(tb testing.TB, mode config.EditAckMode) *renameTwentyFixture { + tb.Helper() + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + tb.Cleanup(func() { pendingFileProjections = previous }) + root := tb.TempDir() + files := map[string]string{"target.go": "package sample\nfunc Target() string { return \"unchanged\" }\n"} + for i := 1; i < 20; i++ { + files[fmt.Sprintf("caller%02d.go", i)] = fmt.Sprintf("package sample\nfunc Caller%02d() string { return Target() }\n", i) + } + require.NoError(tb, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/rename20\n\ngo 1.21\n"), 0600)) + for name, body := range files { + require.NoError(tb, os.WriteFile(filepath.Join(root, name), []byte(body), 0600)) + } + g := graph.New() + idx := indexer.New(g, testRegistry(), config.Default().Index, zap.NewNop()) + _, err := idx.Index(root) + require.NoError(tb, err) + configPath := filepath.Join(tb.TempDir(), "config.yaml") + // Omission exercises the production default full; local is explicit. + configBody := "" + if mode == config.EditAckLocal { + configBody = "edit:\n ack: local\n" + } + require.NoError(tb, os.WriteFile(configPath, []byte(configBody), 0600)) + manager, err := config.NewConfigManager(configPath) + require.NoError(tb, err) + s := NewServer(query.NewEngine(g), g, idx, nil, zap.NewNop(), nil, MultiRepoOptions{ConfigManager: manager}) + s.RunAnalysis() + require.Equal(tb, mode, s.sourceAckPolicy(tb.Context()).mode) + watcher, err := indexer.NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 5}, zap.NewNop()) + require.NoError(tb, err) + s.SetWatcher(watcher) + require.NoError(tb, watcher.Start([]string{root})) + tb.Cleanup(func() { require.NoError(tb, watcher.Stop()); idx.Close() }) + invoke := s.wrapToolHandler(func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + return s.runAcknowledgedEdit(ctx, req, s.handleRenameSymbol) + }) + return &renameTwentyFixture{server: s, root: root, original: files, invoke: invoke} +} + +func (f *renameTwentyFixture) rename(tb testing.TB, before, after string) (time.Duration, []map[string]any) { + tb.Helper() + req := makeReq("rename_symbol", map[string]any{"id": "target.go::" + before, "new_name": after, "format": "json"}) + started := time.Now() + result, err := f.invoke(tb.Context(), req) + elapsed := time.Since(started) + if bench, ok := tb.(*testing.B); ok { + bench.StopTimer() + if result != nil { + bench.Logf("rename_ack_sample mode=%s before=%s after=%s elapsed_ns=%d response=%s", f.server.sourceAckPolicy(tb.Context()).mode, before, after, elapsed.Nanoseconds(), resultText(result)) + } + } + require.NoError(tb, err) + require.NotNil(tb, result) + require.False(tb, result.IsError, "%s", resultText(result)) + tb.Logf("semantic_rename mode=%s before=%s after=%s raw_response=%s", f.server.sourceAckPolicy(tb.Context()).mode, before, after, resultText(result)) + var body map[string]any + require.NoError(tb, json.Unmarshal([]byte(resultText(result)), &body)) + require.Equal(tb, "applied", body["status"]) + require.EqualValues(tb, 20, body["files_written"]) + rows, ok := body["files"].([]any) + require.True(tb, ok) + require.Len(tb, rows, 20) + files := make([]map[string]any, 0, len(rows)) + seen := make(map[string]bool, len(rows)) + for _, raw := range rows { + file, ok := raw.(map[string]any) + require.True(tb, ok) + name, ok := file["file"].(string) + require.True(tb, ok) + require.Contains(tb, f.original, name) + require.False(tb, seen[name]) + seen[name] = true + require.Equal(tb, "applied", file["status"]) + require.Equal(tb, "committed", file["disk_status"]) + require.Equal(tb, string(f.server.sourceAckPolicy(tb.Context()).mode), file["ack_mode"]) + if f.server.sourceAckPolicy(tb.Context()).mode == config.EditAckFull { + require.Equal(tb, "fresh", file["graph_status"], "full mode needs actual completion for every file") + } + current, err := os.ReadFile(filepath.Join(f.root, name)) + require.NoError(tb, err) + require.Equal(tb, strings.ReplaceAll(f.original[name], "Target", after), string(current)) + files = append(files, file) + } + return elapsed, files +} + +// Prepare the NEXT sample from an actually completed graph. This wait occurs +// after the measured ACK, is reported separately, and never grants local ACKs +// full-mode credit or represents sustained offered-load latency. +func (f *renameTwentyFixture) waitForPublication(tb testing.TB, files []map[string]any) time.Duration { + tb.Helper() + started := time.Now() + ctx, cancel := context.WithTimeout(tb.Context(), time.Minute) + defer cancel() + for _, file := range files { + id, ok := file["reindex_receipt"].(string) + if !ok { + // Completed full replies omit the queued-only receipt field. Require + // the actual completion facts already asserted on this same row. + require.Equal(tb, "full", file["ack_mode"]) + require.Equal(tb, "fresh", file["graph_status"]) + require.Equal(tb, true, file["reindexed"]) + continue + } + raw, ok := f.server.mutationReceipts.Load(id) + require.True(tb, ok) + receipt, ok := raw.(*mutationReceipt) + require.True(tb, ok) + select { + case <-receipt.done: + outcome := receipt.outcome(false) + require.NoError(tb, outcome.Err) + require.True(tb, outcome.Reindexed) + case <-ctx.Done(): + tb.Fatal(ctx.Err()) + } + } + // Native watcher echoes can carry a newer generation than the returned + // tool ticket. Prepare the next isolated sample from the CURRENT real + // relations tier for all affected paths, not merely a closed old ticket. + paths := make([]string, 0, len(f.original)) + for path := range f.original { + paths = append(paths, path) + } + slices.Sort(paths) + deadline, ok := ctx.Deadline() + require.True(tb, ok) + fresh := f.server.awaitRelationFreshness(ctx, nil, paths, deadline) + require.NoError(tb, ctx.Err()) + require.Empty(tb, fresh.state, "next-sample preparation must not permit provisional or failed relations: %v", fresh.files) + return time.Since(started) +} + +func TestRenameSymbolTwentyFileMicroFixture(t *testing.T) { + for _, mode := range []config.EditAckMode{config.EditAckFull, config.EditAckLocal} { + t.Run(string(mode), func(t *testing.T) { + fixture := newRenameTwentyFixture(t, mode) + _, files := fixture.rename(t, "Target", "Renamed") + fixture.waitForPublication(t, files) + _, files = fixture.rename(t, "Renamed", "Target") + fixture.waitForPublication(t, files) + }) + } +} + +func BenchmarkRenameSymbolAck20Files(b *testing.B) { + for _, mode := range []config.EditAckMode{config.EditAckFull, config.EditAckLocal} { + b.Run(string(mode), func(b *testing.B) { + fixture := newRenameTwentyFixture(b, mode) + samples := make([]time.Duration, 0, b.N) + var between time.Duration + before, after := "Target", "Renamed" + b.ResetTimer() + for i := 0; i < b.N; i++ { + elapsed, files := fixture.rename(b, before, after) + b.StopTimer() + samples = append(samples, elapsed) + between += fixture.waitForPublication(b, files) + before, after = after, before + b.StartTimer() + } + b.StopTimer() + slices.Sort(samples) + require.NotEmpty(b, samples) + p95 := (95*len(samples)+99)/100 - 1 + b.ReportMetric(float64(samples[p95])/float64(time.Millisecond), "ack-p95-ms") + b.ReportMetric(float64(samples[len(samples)-1])/float64(time.Millisecond), "ack-max-ms") + b.ReportMetric(float64(between)/float64(time.Millisecond)/float64(len(samples)), "between-sample-publication-ms") + b.ReportMetric(float64(len(samples)), "actual-acks") + b.ReportMetric(20, "affected-files") + }) + } +} diff --git a/internal/mcp/safe_delete_propagate.go b/internal/mcp/safe_delete_propagate.go index 2a92f3b6f..4be9adb2a 100644 --- a/internal/mcp/safe_delete_propagate.go +++ b/internal/mcp/safe_delete_propagate.go @@ -3,12 +3,19 @@ package mcp import ( "context" "fmt" + "go/ast" + "go/parser" + "maps" "os" + "path/filepath" + "slices" "sort" "strings" - "github.com/zzet/gortex/internal/agents" + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer/source" ) // SRN-7: transitive propagate-delete. computeCascadeClosure already cascades @@ -16,63 +23,133 @@ import ( // reference the deleted symbol. buildPropagationPlan reads each call site and // classifies it — a standalone statement call can be removed outright; an // embedded reference is flagged for manual patching — and applyRemoveLinePatches -// deletes the removable lines (parse-gate validated) so the delete leaves no -// dangling reference behind. +// deletes the removable lines (text-anchored, parse-gate validated) so the +// delete leaves no dangling reference behind. type callerPatch struct { - File string `json:"file"` - CallerName string `json:"caller_name"` - Line int `json:"line"` - CurrentText string `json:"current_text"` - Action string `json:"action"` // remove_line | manual - Reason string `json:"reason"` - abs string // resolved absolute path (internal) + File string `json:"file"` + CallerName string `json:"caller_name"` + Line int `json:"line"` + CurrentText string `json:"current_text"` + Action string `json:"action"` // remove_line | manual + Reason string `json:"reason"` + NeedsSourceRevalidation bool `json:"source_revalidation_required,omitempty"` + abs string // resolved absolute path (internal) + from, target string + kind graph.EdgeKind + sourceHash [32]byte } // buildPropagationPlan walks the referencing call sites of a symbol and returns // the per-caller patch each one needs. func (s *Server) buildPropagationPlan(ctx context.Context, g graph.Reader, target *graph.Node) []callerPatch { + return s.buildPropagationPlanForPhase(ctx, g, target, false) +} + +func (s *Server) buildPropagationPreview(ctx context.Context, g graph.Reader, target *graph.Node) []callerPatch { + return s.buildPropagationPlanForPhase(ctx, g, target, true) +} + +func (s *Server) buildPropagationPlanForPhase(ctx context.Context, g graph.Reader, target *graph.Node, preview bool) []callerPatch { var plan []callerPatch - seen := make(map[string]bool) - for _, e := range g.GetInEdges(target.ID) { - if !isReferencingEdgeKind(e.Kind) || e.Line == 0 { + seen := make(map[string]int) + for _, edge := range g.GetInEdges(target.ID) { + if !isReferencingEdgeKind(edge.Kind) { continue } - caller := g.GetNode(e.From) - if caller == nil { - continue + patch := callerPatch{File: edge.FilePath, Line: edge.Line, Action: "manual", Reason: "reference has no verified isolated source occurrence", from: edge.From, target: edge.To, kind: edge.Kind} + caller := g.GetNode(edge.From) + if caller != nil { + patch.CallerName = caller.Name + if patch.File == "" { + patch.File = caller.FilePath + } } - key := fmt.Sprintf("%s:%d", caller.FilePath, e.Line) - if seen[key] { + if caller == nil || edge.Line <= 0 || edge.Kind != graph.EdgeCalls || patch.File != caller.FilePath { + plan = append(plan, patch) continue } - seen[key] = true - abs, err := s.resolveNodePath(ctx, caller) if err != nil { + plan = append(plan, patch) continue } - text := readSingleLineAt(abs, e.Line) - action, reason := classifyCallSite(text, target.Name) - plan = append(plan, callerPatch{ - File: caller.FilePath, - CallerName: caller.Name, - Line: e.Line, - CurrentText: strings.TrimSpace(text), - Action: action, - Reason: reason, - abs: abs, - }) + patch.abs = abs + raw, accepted := s.acceptedPropagationSource(ctx, g, caller, abs) + lines := strings.Split(string(raw), "\n") + if len(raw) == 0 || !accepted && !preview || edge.Line > len(lines) { + patch.Reason = "caller source is not the selected graph's accepted content — publish it or patch by hand" + plan = append(plan, patch) + continue + } + patch.CurrentText = strings.TrimSpace(lines[edge.Line-1]) + patch.Action, patch.Reason = classifyCallSite(patch.CurrentText, target.Name) + patch.sourceHash = blake3.Sum256(raw) + if !accepted && patch.Action == "remove_line" { + patch.NeedsSourceRevalidation = true + patch.Reason = "isolated call in current source; this preview requires source and reference revalidation before applying" + } + key := fmt.Sprintf("%s:%d", patch.File, patch.Line) + if previous, ok := seen[key]; ok { + // More than one graph occurrence cannot be discharged by one + // textual line deletion. Keep both references manually actionable. + plan[previous].Action, plan[previous].Reason = "manual", "multiple references share this source line — patch by hand" + patch.Action, patch.Reason = "manual", "multiple references share this source line — patch by hand" + } else { + seen[key] = len(plan) + } + plan = append(plan, patch) } sort.Slice(plan, func(a, b int) bool { if plan[a].File != plan[b].File { return plan[a].File < plan[b].File } - return plan[a].Line < plan[b].Line + if plan[a].Line != plan[b].Line { + return plan[a].Line < plan[b].Line + } + if plan[a].from != plan[b].from { + return plan[a].from < plan[b].from + } + if plan[a].kind != plan[b].kind { + return plan[a].kind < plan[b].kind + } + return plan[a].target < plan[b].target }) return plan } +// Automatic source removal needs the same selected graph's accepted bytes, +// not merely a matching callee name on a line in a changed file. Unknown or +// transformed identities conservatively stay manual; there is no canonical +// fallback for a routed reader. +func (s *Server) acceptedPropagationSource(ctx context.Context, reader graph.Reader, caller *graph.Node, abs string) ([]byte, bool) { + files, err := source.NewFilesystemSource(filepath.Dir(abs)) + if err != nil { + return nil, false + } + defer files.Close() + raw, _, err := files.ReadRegularFile(ctx, filepath.Base(abs), 1<<20) + if err != nil { + return nil, false + } + checked, ok := reader.(graph.ConstantValueProjectionReader) + if !ok { + return raw, false + } + prefix := caller.RepoPrefix + if owner, _ := s.indexerForRel(caller.FilePath); owner != nil { + prefix = owner.RepoPrefix() + } + key := graph.ConstantFileKey{RepoPrefix: prefix, FilePath: caller.FilePath} + projection, err := checked.ReadConstantValueProjectionContext(ctx, nil, []graph.ConstantFileKey{key}) + if err != nil { + return raw, false + } + row, ok := projection.Files[key] + sum := blake3.Sum256(raw) + return raw, ok && row.ContentHash == fmt.Sprintf("%x", sum[:]) +} + // classifyCallSite decides whether a reference line is a standalone statement // call (safe to delete whole) or an embedded reference (manual). Conservative: // anything ambiguous is manual. @@ -92,60 +169,126 @@ func classifyCallSite(line, target string) (action, reason string) { } } core := strings.TrimPrefix(strings.TrimPrefix(body, "defer "), "go ") - if strings.HasSuffix(core, ")") && - (strings.HasPrefix(core, target+"(") || strings.Contains(core, "."+target+"(")) { - return "remove_line", "standalone statement call — the line can be removed" + // Parse one expression, not a line prefix: Target(); Keep() is two + // statements, and deleting its whole line would erase unrelated work. + if expression, err := parser.ParseExpr(core); err == nil { + if call, ok := expression.(*ast.CallExpr); ok { + name := "" + switch callee := call.Fun.(type) { + case *ast.Ident: + name = callee.Name + case *ast.SelectorExpr: + name = callee.Sel.Name + } + if name == target { + return "remove_line", "isolated standalone call — the line can be removed" + } + } } return "manual", "could not confirm a standalone call — patch by hand" } -// applyRemoveLinePatches deletes the remove_line patches from their files, -// grouped per file and applied bottom-up so earlier line numbers stay valid. -// A patch whose removal would introduce new parse errors is not applied and is -// returned in failed. Manual patches are ignored here. -func (s *Server) applyRemoveLinePatches(plan []callerPatch) (applied, failed []callerPatch, err error) { +// applyRemoveLinePatches deletes the remove_line patches from their files. +// Manual patches are ignored here. Every caller file is locked before any is +// written, so a lock that cannot be taken (a cancelled request, a path the +// checkout guard refuses) leaves them all untouched. Each file is then handled +// by removePlannedLines; a refused patch lands in failed exactly once, with +// its Reason replaced by the refusal code. +func (s *Server) applyRemoveLinePatches(ctx context.Context, plan []callerPatch) (applied, failed []callerPatch, err error) { byFile := make(map[string][]callerPatch) for _, p := range plan { if p.Action == "remove_line" { byFile[p.abs] = append(byFile[p.abs], p) } } - for abs, patches := range byFile { - content, readErr := os.ReadFile(abs) - if readErr != nil { - return applied, failed, fmt.Errorf("read %s: %w", abs, readErr) - } - lines := strings.Split(string(content), "\n") - // Apply bottom-up so deletions don't shift later indices. - sort.Slice(patches, func(a, b int) bool { return patches[a].Line > patches[b].Line }) - kept := patches[:0] - newLines := append([]string{}, lines...) - for _, p := range patches { - if p.Line < 1 || p.Line > len(newLines) { - failed = append(failed, p) - continue - } - newLines = append(newLines[:p.Line-1], newLines[p.Line:]...) - kept = append(kept, p) - } - newContent := []byte(strings.Join(newLines, "\n")) - // Parse gate: never let a propagated removal corrupt a file. - relPath := patches[0].File - if gate := checkParseGate(relPath, content, newContent); gate.Blocked { - failed = append(failed, patches...) - continue + paths := slices.Sorted(maps.Keys(byFile)) + ctx, release, err := acquireMutationPaths(ctx, paths) + if err != nil { + return nil, nil, fmt.Errorf("lock caller files: %w", err) + } + defer release() + applied, failed, writes, err := s.applyRemoveLinePatchesHeld(ctx, plan) + s.completeCommittedSourceMutations(ctx, writes, release) + return applied, failed, err +} + +func (s *Server) applyRemoveLinePatchesHeld(ctx context.Context, plan []callerPatch) (applied, failed []callerPatch, writes []committedSourceMutation, err error) { + byFile := make(map[string][]callerPatch) + for _, patch := range plan { + if patch.Action == "remove_line" { + byFile[patch.abs] = append(byFile[patch.abs], patch) } - perm := os.FileMode(0o644) - if info, statErr := os.Stat(abs); statErr == nil { - perm = info.Mode().Perm() + } + for _, abs := range slices.Sorted(maps.Keys(byFile)) { + ok, refused, commit, ferr := s.removePlannedLines(ctx, abs, byFile[abs]) + applied = append(applied, ok...) + failed = append(failed, refused...) + if commit != nil && ferr == nil { + writes = append(writes, captureCommittedSourceMutation(ctx, abs, commit)) } - if writeErr := agents.AtomicWriteFile(abs, newContent, perm); writeErr != nil { - return applied, failed, fmt.Errorf("write %s: %w", abs, writeErr) + if ferr != nil { + return applied, failed, writes, ferr } - s.reindexFile(abs) - applied = append(applied, kept...) } - return applied, failed, nil + return applied, failed, writes, nil +} + +// removePlannedLines applies one file's patches bottom-up, so earlier line +// numbers stay valid; the caller holds the file's mutation lock, so no +// concurrent writer lands between the read and the write. A patch is anchored +// on its planned text: a line that was edited or shifted since planning is +// refused as line_changed instead of being deleted by number. When the +// surviving removals would introduce new parse errors the file is left +// untouched and they are refused as parse_errors. Nothing is written when +// every patch is refused. +func (s *Server) removePlannedLines(ctx context.Context, abs string, patches []callerPatch) (applied, failed []callerPatch, committed *mutationCommitRecord, err error) { + content, err := os.ReadFile(abs) + if err != nil { + return nil, nil, nil, fmt.Errorf("read %s: %w", abs, err) + } + lines := strings.Split(string(content), "\n") + sort.Slice(patches, func(a, b int) bool { return patches[a].Line > patches[b].Line }) + for _, p := range patches { + switch { + case p.NeedsSourceRevalidation: + p.Reason = "source_unverified" + failed = append(failed, p) + case p.Line < 1 || p.Line > len(lines): + p.Reason = "line_out_of_range" + failed = append(failed, p) + case strings.TrimSpace(lines[p.Line-1]) != p.CurrentText: + p.Reason = "line_changed" + failed = append(failed, p) + case p.sourceHash != ([32]byte{}) && p.sourceHash != blake3.Sum256(content): + p.Reason = "file_changed" + failed = append(failed, p) + default: + lines = append(lines[:p.Line-1], lines[p.Line:]...) + applied = append(applied, p) + } + } + if len(applied) == 0 { + return nil, failed, nil, nil + } + newContent := []byte(strings.Join(lines, "\n")) + // Parse gate: never let a propagated removal corrupt a file. + prepared, gate := s.prepareAcknowledgedProjection(ctx, patches[0].File, abs, content, newContent) + if gate.Blocked { + for _, p := range applied { + p.Reason = "parse_errors" + failed = append(failed, p) + } + return nil, failed, nil, nil + } + perm := os.FileMode(0o644) + if info, statErr := os.Stat(abs); statErr == nil { + perm = info.Mode().Perm() + } + commit, writeErr := s.commitFileMutation(ctx, "safe_delete_symbol", "", "", patches[0].File, abs, newContent, perm, sourceMutationOperation{kind: sourceWrite, projection: prepared}) + if writeErr != nil { + return nil, failed, nil, fmt.Errorf("write %s: %w", abs, writeErr) + } + return applied, failed, commit, nil } func countManual(plan []callerPatch) int { diff --git a/internal/mcp/safe_delete_propagate_test.go b/internal/mcp/safe_delete_propagate_test.go index bd09cf080..57416eae7 100644 --- a/internal/mcp/safe_delete_propagate_test.go +++ b/internal/mcp/safe_delete_propagate_test.go @@ -3,9 +3,11 @@ package mcp import ( "context" "encoding/json" + "fmt" "os" "path/filepath" "testing" + "time" mcplib "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" @@ -102,3 +104,132 @@ func TestPropagateDeletePlanAndApply(t *testing.T) { require.NoError(t, err) require.NotContains(t, string(callerSrc), "Target()", "the call site should be removed") } + +// A propagate plan is built from one read of each caller line, and the apply +// re-reads the file later. A line edited or shifted in between must be +// refused rather than deleted by number, every refusal must be reported once, +// and a file with nothing left to remove must not be rewritten. +func TestPropagateRefusesStaleLines(t *testing.T) { + dir := t.TempDir() + write := func(name, src string) { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644)) + } + write("del.go", "package p\n\nfunc Target() {}\n") + write("edited.go", "package p\n\nfunc Edited() {\n\tTarget()\n}\n") + write("shifted.go", "package p\n\nfunc Shifted() {\n\tTarget()\n}\n") + write("gate.go", "package p\n\nfunc Gate() {\n\tTarget(\n\t)\n}\n") + + g := graph.New() + idx := indexer.New(g, testRegistry(), config.Default().Index, zap.NewNop()) + _, err := idx.Index(dir) + require.NoError(t, err) + srv := NewServer(query.NewEngine(g), g, idx, nil, zap.NewNop(), nil) + + var target *graph.Node + for _, n := range g.AllNodes() { + if n.Name == "Target" && n.Kind == graph.KindFunction { + target = n + } + } + require.NotNil(t, target) + ctx := context.Background() + plan := srv.buildPropagationPlan(ctx, g, target) + removable := 0 + for _, p := range plan { + if p.Action == "remove_line" { + removable++ + } + } + require.Equal(t, 2, removable, "edited.go and shifted.go each hold one standalone call: %+v", plan) + + // gate.go: a planned line whose removal breaks the file, and a planned + // line past the end of the same file. + gateAbs := filepath.Join(dir, "gate.go") + plan = append(plan, + callerPatch{File: "gate.go", Line: 4, CurrentText: "Target(", Action: "remove_line", abs: gateAbs}, + callerPatch{File: "gate.go", Line: 40, CurrentText: "Target()", Action: "remove_line", abs: gateAbs}, + ) + + // Concurrent edits after planning. + write("edited.go", "package p\n\nfunc Edited() {\n\tKeep()\n}\n") + write("shifted.go", "package p\n\nfunc Shifted() {\n\tprepare()\n\tTarget()\n}\n") + + before := map[string][]byte{} + stats := map[string]os.FileInfo{} + for _, name := range []string{"edited.go", "shifted.go", "gate.go"} { + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + require.NoError(t, err) + info, err := os.Stat(path) + require.NoError(t, err) + before[name], stats[name] = data, info + } + + applied, failed, err := srv.applyRemoveLinePatches(ctx, plan) + require.NoError(t, err) + require.Empty(t, applied) + + got := map[string]string{} + for _, p := range failed { + key := fmt.Sprintf("%s:%d", p.File, p.Line) + _, dup := got[key] + require.False(t, dup, "patch %s reported twice in failed: %+v", key, failed) + got[key] = p.Reason + } + require.Equal(t, map[string]string{ + "edited.go:4": "line_changed", + "shifted.go:4": "line_changed", + "gate.go:4": "parse_errors", + "gate.go:40": "line_out_of_range", + }, got) + + for name, data := range before { + path := filepath.Join(dir, name) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, string(data), string(after), "%s must stay byte-identical", name) + info, err := os.Stat(path) + require.NoError(t, err) + require.True(t, os.SameFile(stats[name], info), "%s was rewritten although every patch was refused", name) + } +} + +// Every caller file is locked before any is written. A lock the apply cannot +// take, here one held past the request's deadline, must leave all of them as +// they were, not only the files the apply had not reached yet. +func TestPropagateWritesNothingWhenACallerLockFails(t *testing.T) { + dir := t.TempDir() + const src = "package p\n\nfunc C() {\n\tTarget()\n}\n" + for i := range 8 { + require.NoError(t, os.WriteFile(filepath.Join(dir, fmt.Sprintf("c%d.go", i)), []byte(src), 0o644)) + } + g := graph.New() + idx := indexer.New(g, testRegistry(), config.Default().Index, zap.NewNop()) + _, err := idx.Index(dir) + require.NoError(t, err) + srv := NewServer(query.NewEngine(g), g, idx, nil, zap.NewNop(), nil) + + var plan []callerPatch + for i := range 8 { + name := fmt.Sprintf("c%d.go", i) + plan = append(plan, callerPatch{File: name, Line: 4, CurrentText: "Target()", Action: "remove_line", abs: filepath.Join(dir, name)}) + } + _, release, err := acquireMutationPath(context.Background(), plan[len(plan)-1].abs) + require.NoError(t, err) + defer release() + + // Map order picks the file a file-by-file apply would lock first, so + // repeat until such an apply would have written one. + for range 3 { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + applied, _, err := srv.applyRemoveLinePatches(ctx, plan) + cancel() + require.Error(t, err) + require.Empty(t, applied) + for _, p := range plan { + data, err := os.ReadFile(p.abs) + require.NoError(t, err) + require.Equal(t, src, string(data), "%s was rewritten although the apply failed", p.File) + } + } +} diff --git a/internal/mcp/search_gcx_golden_path_test.go b/internal/mcp/search_gcx_golden_path_test.go new file mode 100644 index 000000000..f7b6a61a4 --- /dev/null +++ b/internal/mcp/search_gcx_golden_path_test.go @@ -0,0 +1,33 @@ +package mcp + +import ( + "bytes" + "strings" + "testing" + + wire "github.com/gortexhq/gcx-go" + "github.com/stretchr/testify/require" +) + +func TestSearchGoldenNormalizationUsesActualGCXPathEscaping(t *testing.T) { + for _, root := range []string{`D:\owned\checkout`, `\\host\share\checkout`, "/owned/tab\tand\nnewline"} { + t.Run(root, func(t *testing.T) { + encode := func(path string) string { + var buffer bytes.Buffer + encoder := wire.NewEncoder(&buffer, wire.Header{Tool: "search_symbols", Fields: []string{"id", "path_abs", "sig", "unrelated", "empty"}}) + require.NoError(t, encoder.WriteComment("preserve header, comment and trailing empty cell")) + require.NoError(t, encoder.WriteRow("server.go::Handle", path, "func literal(\"D:\\code\")\tline\nnext", root+"-sibling", "")) + require.NoError(t, encoder.WriteRow("unrelated.go::Other", root+"-sibling/unrelated.go", `func keep("D:\literal")`, "", "")) + require.NoError(t, encoder.Close()) + return buffer.String() + } + separator := "/" + if strings.Contains(root, `\`) { + separator = `\` + } + actual := encode(root + separator + "server" + separator + "server.go") + want := encode("$ROOT/server/server.go") + require.Equal(t, want, normalizeSearchGoldenRoots(actual, []string{root}), "decode only the path cell; unrelated wire bytes must stay exact") + }) + } +} diff --git a/internal/mcp/search_golden_test.go b/internal/mcp/search_golden_test.go new file mode 100644 index 000000000..f5c43c544 --- /dev/null +++ b/internal/mcp/search_golden_test.go @@ -0,0 +1,280 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "flag" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + wire "github.com/gortexhq/gcx-go" + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/query" +) + +var updateSearchGolden = flag.Bool("update-search-golden", false, "rewrite the search_symbols / search_text goldens") + +// searchGoldenCursor matches a page cursor, which names a sequence minted at +// random on every call. +var searchGoldenCursor = regexp.MustCompile(`"next_cursor":"[A-Za-z0-9_-]+"`) + +var searchGoldenAbsolutePath = regexp.MustCompile(`("absolute_file_path"\s*:\s*)("(?:\\.|[^"\\])*")`) + +// searchGoldenFixture is a small module whose names share prefixes, so exact, +// prefix and concept queries rank different candidate sets, and whose bodies +// repeat literals across files, so text queries hit several paths. +var searchGoldenFixture = map[string]string{ + "go.mod": "module example.com/app\n\ngo 1.22\n", + "config/config.go": `package config + +// Config holds the server settings. +type Config struct { + Port int + Host string +} + +// LoadConfig reads the settings file at path. +func LoadConfig(path string) (*Config, error) { + return &Config{Port: 8080, Host: "localhost"}, nil +} + +// Validate reports whether the settings are usable. +func (c *Config) Validate() error { return nil } +`, + "server/server.go": `package server + +import "example.com/app/config" + +// Server answers requests. +type Server struct { + cfg *config.Config +} + +// NewServer builds a server from cfg. +func NewServer(cfg *config.Config) *Server { return &Server{cfg: cfg} } + +// HandleRequest serves one request. +func (s *Server) HandleRequest(path string) string { + if err := s.cfg.Validate(); err != nil { + return "invalid config" + } + return "handled " + path +} + +// HandleHealth answers the health probe. +func (s *Server) HandleHealth() string { return "handled health" } +`, + "main.go": `package main + +import ( + "example.com/app/config" + "example.com/app/server" +) + +func main() { + cfg, _ := config.LoadConfig("app.yaml") + srv := server.NewServer(cfg) + _ = srv.HandleRequest("/") + _ = srv.HandleHealth() +} +`, +} + +// TestSearchOutputsMatchGoldens pins the exact bytes search_symbols and +// search_text answer with on a quiet checkout, in every output mode. Work that +// lets search read through just-edited files must leave this path untouched: +// with nothing pending, the answer is byte-for-byte what it was before. The +// only rewrites are the checkout's temporary root, which reads as $ROOT, and +// the random page cursor, which reads as $CURSOR. +// +// Regenerate with -update-search-golden, on a tree whose search code is the +// reference. +func TestSearchOutputsMatchGoldens(t *testing.T) { + dir := t.TempDir() + for rel, body := range searchGoldenFixture { + path := filepath.Join(dir, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(body), 0o644)) + } + // The store and engine are wired as the daemon wires them, so symbol + // queries rank through the store's full-text index, not the substring + // fallback a bare engine takes. + store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "search.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + idx := indexer.New(store, testRegistry(), config.Default().Index, zap.NewNop()) + _, err = idx.Index(dir) + require.NoError(t, err) + requireSymbolFTS(t, store) + eng := query.NewEngine(store) + eng.SetSearchProvider(idx.Search) + srv := NewServer(eng, store, idx, nil, zap.NewNop(), nil) + srv.RunAnalysis() + // The resolved spelling goes first: on macOS it is the temporary root + // with a /private prefix. + roots := []string{dir} + if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != dir { + roots = []string{resolved, dir} + } + + cases := []struct { + name string + tool string + args map[string]any + }{ + {"symbols_exact", "search_symbols", map[string]any{"query": "HandleRequest"}}, + {"symbols_prefix", "search_symbols", map[string]any{"query": "Handle"}}, + {"symbols_concept", "search_symbols", map[string]any{"query": "load settings config"}}, + {"symbols_limit", "search_symbols", map[string]any{"query": "Handle", "limit": 1}}, + {"symbols_kind", "search_symbols", map[string]any{"query": "Config", "kind": "type"}}, + {"symbols_missing", "search_symbols", map[string]any{"query": "NoSuchSymbolAnywhere"}}, + {"symbols_json", "search_symbols", map[string]any{"query": "Handle", "format": "json"}}, + {"symbols_compact", "search_symbols", map[string]any{"query": "Handle", "compact": true}}, + {"symbols_toon", "search_symbols", map[string]any{"query": "Handle", "format": "toon"}}, + {"symbols_gcx", "search_symbols", map[string]any{"query": "Handle", "format": "gcx"}}, + {"text_literal", "search_text", map[string]any{"query": "handled"}}, + {"text_limit", "search_text", map[string]any{"query": "config", "limit": 2}}, + {"text_path", "search_text", map[string]any{"query": "config", "path": "server"}}, + {"text_regexp", "search_text", map[string]any{"query": `Handle[A-Z][a-z]+\(`, "regexp": true}}, + {"text_missing", "search_text", map[string]any{"query": "no such literal anywhere"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := callTool(t, srv, tc.tool, tc.args) + require.NotEmpty(t, res.Content) + got := res.Content[0].(mcplib.TextContent).Text + got = normalizeSearchGoldenRoots(got, roots) + got = searchGoldenCursor.ReplaceAllLiteralString(got, `"next_cursor":"$CURSOR"`) + golden := filepath.Join("testdata", "search_golden", tc.name+".txt") + if *updateSearchGolden { + require.NoError(t, os.MkdirAll(filepath.Dir(golden), 0o755)) + require.NoError(t, os.WriteFile(golden, []byte(got), 0o644)) + return + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + require.Equal(t, string(want), got) + }) + } +} + +func normalizeSearchGoldenRoots(got string, roots []string) string { + // Rewrite only the path string token, retaining field order, whitespace, + // exact numeric spelling and every unrelated source string byte. + if json.Valid([]byte(got)) { + return searchGoldenAbsolutePath.ReplaceAllStringFunc(got, func(match string) string { + parts := searchGoldenAbsolutePath.FindStringSubmatch(match) + var path string + if json.Unmarshal([]byte(parts[2]), &path) != nil { + return match + } + normalized := normalizeSearchGoldenPath(path, roots) + if normalized == path { + return match + } + encoded, err := json.Marshal(normalized) + if err != nil { + return match + } + return parts[1] + string(encoded) + }) + } + if strings.HasPrefix(got, "GCX1 ") { + lines := strings.Split(got, "\n") + column := -1 + for _, word := range strings.Fields(lines[0]) { + if fields, ok := strings.CutPrefix(word, "fields="); ok { + for i, name := range strings.Split(fields, ",") { + if name == "path_abs" { + column = i + } + } + } + } + if column >= 0 { + for i, line := range lines[1:] { + if strings.HasPrefix(line, "#") { + continue + } + fields := strings.Split(line, "\t") + if column < len(fields) { + fields[column] = normalizeSearchGoldenGCXPath(fields[column], roots) + lines[i+1] = strings.Join(fields, "\t") + } + } + } + return strings.Join(lines, "\n") + } + for _, root := range roots { + got = strings.ReplaceAll(got, root, "$ROOT") + } + return got +} + +func normalizeSearchGoldenGCXPath(field string, roots []string) string { + // A GCX cell escapes backslashes itself. Decode ONLY this declared path + // cell; rebuilding the row would rewrite unrelated source/signature bytes. + decoder := wire.NewDecoder(strings.NewReader("GCX1 tool=golden fields=path_abs\n" + field + "\n")) + if row, err := decoder.NextRow(); err == nil { + path := row["path_abs"] + if normalized := normalizeSearchGoldenPath(path, roots); normalized != path { + var encoded bytes.Buffer + encoder := wire.NewEncoder(&encoded, wire.Header{Tool: "golden", Fields: []string{"path_abs"}}) + if encoder.WriteRow(normalized) == nil && encoder.Close() == nil { + if _, row, ok := strings.Cut(encoded.String(), "\n"); ok { + return strings.TrimSuffix(row, "\n") + } + } + } + } + // Retain compatibility with older hand-written raw-path fixtures. A + // non-owned path keeps its original escaped spelling byte for byte. + return normalizeSearchGoldenPath(field, roots) +} + +func normalizeSearchGoldenPath(path string, roots []string) string { + original := path + path = strings.ReplaceAll(path, `\`, "/") + for _, root := range roots { + root = strings.TrimSuffix(strings.ReplaceAll(root, `\`, "/"), "/") + if path == root { + return "$ROOT" + } + if strings.HasPrefix(path, root+"/") { + return "$ROOT" + strings.TrimPrefix(path, root) + } + } + return original +} + +func TestSearchGoldenRootNormalizationPreservesActualWindowsPaths(t *testing.T) { + root := `D:\a\_temp\owned` + for _, mode := range []string{"json", "gcx"} { + t.Run(mode, func(t *testing.T) { + var actual, want string + if mode == "json" { + value := map[string]any{"absolute_file_path": root + `\server\server.go`, "doc": "literal " + root, "unrelated": root + `-sibling\server.go`} + data, err := json.Marshal(value) + require.NoError(t, err) + actual = string(data) + value["absolute_file_path"] = "$ROOT/server/server.go" + data, err = json.Marshal(value) + require.NoError(t, err) + want = string(data) + } else { + actual = "GCX1 tool=search_symbols fields=id,path_abs,sig\nserver::Handle\t" + root + `\server\server.go` + "\t" + `func literal("D:\code")` + "\n" + want = "GCX1 tool=search_symbols fields=id,path_abs,sig\nserver::Handle\t$ROOT/server/server.go\t" + `func literal("D:\code")` + "\n" + } + require.Equal(t, want, normalizeSearchGoldenRoots(actual, []string{root})) + }) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 8e3eb18c5..b7f46b4b1 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -131,6 +131,8 @@ func (sh *symbolHistory) All() map[string][]SymbolModification { // Server wraps the MCP server with Gortex-specific tools. type Server struct { + editAckMode config.EditAckMode + editAckConfigErr error // editOperations covers actual source-edit handler completions across this // Server's sessions/views, including handlers that outlive their caller. // Native filesystem stimuli remain harness-owned, not inferred from events. @@ -1487,6 +1489,8 @@ func prependUnique(slice []string, item string, maxLen int) []string { // MultiRepoOptions holds optional multi-repo components for the Server. // When nil or zero-valued, the server operates in single-repo mode. type MultiRepoOptions struct { + // EditAckMode is startup policy; empty inherits global config, then full. + EditAckMode config.EditAckMode MultiIndexer *indexer.MultiIndexer ConfigManager *config.ConfigManager // CheckoutLifecycle owns every side effect of tracking and forgetting a @@ -1952,6 +1956,17 @@ func NewServer(engine *query.Engine, g graph.Store, idx *indexer.Indexer, watche s.watcher = watcher } + ackConfig := config.EditConfig{} + if len(opts) > 0 { + if opts[0].ConfigManager != nil { + ackConfig = opts[0].ConfigManager.Global().Edit + } + if opts[0].EditAckMode != "" { + ackConfig.Ack = opts[0].EditAckMode + } + } + s.editAckMode, s.editAckConfigErr = ackConfig.AckMode() + // Apply multi-repo options if provided. if len(opts) > 0 { o := opts[0] @@ -3777,6 +3792,11 @@ func (s *Server) SetWatcher(w watcherHistory) { s.watcherMu.Lock() s.watcher = w s.watcherMu.Unlock() + if sourceEvents, ok := w.(interface{ OnSourceEvent(func(string)) }); ok { + sourceEvents.OnSourceEvent(func(path string) { + pendingFileProjections.markSuspect(path) + }) + } // Register callback to track symbol modifications for // get_symbol_history AND fan stale_refs notifications to any diff --git a/internal/mcp/source_ack_mode_frontier_test.go b/internal/mcp/source_ack_mode_frontier_test.go new file mode 100644 index 000000000..a9a37d3bc --- /dev/null +++ b/internal/mcp/source_ack_mode_frontier_test.go @@ -0,0 +1,213 @@ +package mcp + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" +) + +func TestEditAckLocalDurableBatchDoesNotWaitForBackground(t *testing.T) { + s, root := ackModeServer(t, "local") + t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions")) + paths := []string{writeAtomicBatchFixture(t, root, "a.txt", "before\n"), writeAtomicBatchFixture(t, root, "b.txt", "before\n")} + published := make(chan indexer.MutationResult, 2) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + complete := sync.OnceFunc(func() { + for range 2 { + published <- indexer.MutationResult{Reindexed: true} + } + close(published) + }) + joined := make(chan struct{}) + var receipt batchTransactionReceipt + var runErr error + go func() { + defer close(joined) + receipt, runErr = s.runBatchTransaction(t.Context(), []batchEditItem{atomicFileEdit(paths[0], "before", "after"), atomicFileEdit(paths[1], "before", "after")}, "local-batch") + }() + defer func() { + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned local durable batch did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.ElementsMatch(t, paths, admitted) + case <-time.After(3 * time.Second): + t.Fatal("local durable batch did not admit") + } + select { + case <-joined: + case <-time.After(time.Second): + t.Fatal("edit.ack local durable batch waited for background publication") + } + require.NoError(t, runErr) + require.Equal(t, "committed", receipt.Status) + require.Equal(t, "pending", receipt.GraphStatus) + raw, err := json.Marshal(receipt) + require.NoError(t, err) + require.Contains(t, string(raw), `"ack_mode":"local"`) + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) +} + +type ackModeCancelWatcher struct { + mutationTestWatcher + cancel context.CancelFunc +} + +func (w ackModeCancelWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + ticket, err := w.mutationTestWatcher.EnqueueToolMutation(ctx, path) + w.cancel() + return ticket, err +} + +func TestEditAckLocalCancellationAfterCommitRetainsAdmission(t *testing.T) { + s, root := ackModeServer(t, "local") + published := make(chan indexer.MutationResult, 1) + defer close(published) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.SetWatcher(ackModeCancelWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, cancel: cancel}) + path := filepath.Join(root, "main.go") + request := editFileRequest(map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() { println(1) }", "format": "json"}) + result, err := s.handleEditFile(ctx, request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(raw), "println(1)") + payload := extractTextResult(t, result) + require.Equal(t, "local", payload["ack_mode"]) + require.Equal(t, true, payload["reindex_pending"]) + require.NotEmpty(t, payload["reindex_receipt"]) +} + +type ackModeTierWatcher struct { + watchedTierFixture + scope string + admitted chan struct{} +} + +func (w *ackModeTierWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + ticket, err := w.mutationTestWatcher.EnqueueToolMutation(ctx, path) + if err != nil { + return nil, err + } + now := time.Now() + state := indexer.MutationTierState{Generation: ticket.Generation, AckAt: now, FileGraphAt: now} + if w.scope == "derived" { + state.RelationsAt = now + } + w.mu.Lock() + w.paths[path] = state + w.mu.Unlock() + close(w.admitted) + return ticket, nil +} + +func TestEditAckRequireFreshUsesRequestedTierBeforeWholePatch(t *testing.T) { + for _, scope := range []string{"file", "relations", "derived"} { + t.Run(scope, func(t *testing.T) { + s, root := ackModeServer(t, "local") + path := filepath.Join(root, "main.go") + published := make(chan indexer.MutationResult, 1) + watcher := &ackModeTierWatcher{watchedTierFixture: watchedTierFixture{mutationTestWatcher: mutationTestWatcher{done: published}, paths: make(map[string]indexer.MutationTierState)}, scope: scope, admitted: make(chan struct{})} + s.SetWatcher(watcher) + complete := sync.OnceFunc(func() { published <- indexer.MutationResult{Reindexed: true}; close(published) }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.mutationReindexWait = time.Minute + waiting := make(chan struct{}, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { waiting <- struct{}{}; return true } + request := editFileRequest(map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() { println(1) }", "format": "json", "require_fresh": true, "freshness_scope": scope, "wait_deadline": time.Now().Add(5 * time.Second).Format(time.RFC3339Nano)}) + joined := make(chan struct{}) + var result *mcplib.CallToolResult + var runErr error + go func() { defer close(joined); result, runErr = s.runAcknowledgedEdit(ctx, request, s.handleEditFile) }() + defer func() { + cancel() + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned tier ACK did not join") + } + }() + select { + case <-watcher.admitted: + case <-time.After(3 * time.Second): + t.Fatal("tier ACK never admitted") + } + if scope != "file" { + select { + case <-waiting: + case <-time.After(time.Second): + t.Fatal("pending requested tier did not enter unlocked wait") + } + select { + case <-joined: + t.Fatal("file-graph stage incorrectly certified pending later tier") + default: + } + watcher.mu.Lock() + state := watcher.paths[path] + if scope == "relations" { + state.RelationsAt = time.Now() + } else { + state.DerivedAt = time.Now() + } + watcher.paths[path] = state + watcher.mu.Unlock() + } + select { + case <-joined: + case <-time.After(time.Second): + t.Fatal("requested published tier remained coupled to whole-patch completion") + } + require.NoError(t, runErr) + require.False(t, result.IsError, "%v", result.Content) + require.Contains(t, toolResultText(result), `"reindex_pending":true`) + require.NotContains(t, toolResultText(result), `"graph_status":"fresh"`) + }) + } +} + +func TestEditAckLocalMoveDoesNotClaimBackgroundWait(t *testing.T) { + s, _ := setupMoveInlineRepo(t, map[string]string{"pkg/a.go": "package pkg\nfunc Foo() int { return 42 }\n", "pkg/b.go": "package pkg\nfunc Bar() int { return 1 }\n"}) + s.editAckMode = config.EditAckLocal + published := make(chan indexer.MutationResult, 2) + defer close(published) + s.SetWatcher(&renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)}) + s.mutationReindexWait = time.Minute + request := mcplib.CallToolRequest{} + request.Params.Name = "move_symbol" + request.Params.Arguments = map[string]any{"id": "pkg/a.go::Foo", "target_file": "pkg/b.go", "format": "json"} + result, err := s.runAcknowledgedEdit(t.Context(), request, s.handleMoveSymbol) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + response := decodeFileOpsResult(t, result) + for _, raw := range response["file_observations"].([]any) { + id := raw.(map[string]any)["receipt"].(string) + status, ok := s.moveFileObservationPayload(t.Context(), id) + require.True(t, ok) + require.Equal(t, "admitted", status["requested_background_state"]) + require.Equal(t, map[string]string{"publish": "applicable", "reindex_wait": "not_applicable_fast_ack"}, status["acknowledgement_phase_applicability"], "a queued ticket is not evidence that local mode waited") + require.Nil(t, status["direct_reconcile"]) + require.Nil(t, status["read_assisted_reconcile"]) + } +} diff --git a/internal/mcp/source_ack_mode_test.go b/internal/mcp/source_ack_mode_test.go new file mode 100644 index 000000000..4b5409e17 --- /dev/null +++ b/internal/mcp/source_ack_mode_test.go @@ -0,0 +1,121 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" + "go.uber.org/zap" +) + +func ackModeServer(t *testing.T, mode string) (*Server, string) { + t.Helper() + s, root := setupTestServer(t) + file := filepath.Join(t.TempDir(), "config.yaml") + body := "" + if mode != "" { + body = "edit:\n ack: " + mode + "\n" + } + require.NoError(t, os.WriteFile(file, []byte(body), 0o600)) + cm, err := config.NewConfigManager(file) + require.NoError(t, err) + s = NewServer(s.engine, s.graph, s.indexer, nil, zap.NewNop(), nil, MultiRepoOptions{ConfigManager: cm}) + return s, root +} + +func TestEditAckLocalConfigurationReturnsBeforeQueuedPublication(t *testing.T) { + s, root := ackModeServer(t, "local") + published := make(chan indexer.MutationResult, 1) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + complete := sync.OnceFunc(func() { published <- indexer.MutationResult{Reindexed: true}; close(published) }) + joined := make(chan struct{}) + var result *mcplib.CallToolResult + var resultErr error + request := editFileRequest(map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(1) }", "format": "json"}) + go func() { defer close(joined); result, resultErr = s.runAcknowledgedEdit(ctx, request, s.handleEditFile) }() + defer func() { + cancel() + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned local ACK did not join") + } + }() + select { + case <-watcher.announced: + case <-time.After(3 * time.Second): + t.Fatal("local ACK did not admit physical write") + } + select { + case <-joined: + case <-time.After(time.Second): + t.Fatal("explicit edit.ack=local waited for unpublished background work") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + text := toolResultText(result) + require.Contains(t, text, `"ack_mode":"local"`) + require.Contains(t, text, `"reindex_pending":true`) + require.NotContains(t, text, `"graph_status":"fresh"`) +} + +func TestEditAckDefaultAndExplicitFullWaitForActualPublication(t *testing.T) { + for _, mode := range []string{"", "full"} { + t.Run("mode="+mode, func(t *testing.T) { + s, root := ackModeServer(t, mode) + published := make(chan indexer.MutationResult, 1) + s.SetWatcher(mutationTestWatcher{done: published}) + s.mutationReindexWait = time.Minute + waiting := make(chan struct{}, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { waiting <- struct{}{}; return true } + complete := sync.OnceFunc(func() { published <- indexer.MutationResult{Reindexed: true}; close(published) }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + joined := make(chan struct{}) + var result *mcplib.CallToolResult + var resultErr error + request := editFileRequest(map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(1) }", "format": "json"}) + go func() { defer close(joined); result, resultErr = s.runAcknowledgedEdit(ctx, request, s.handleEditFile) }() + defer func() { + cancel() + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned full ACK did not join") + } + }() + select { + case <-waiting: + case <-time.After(3 * time.Second): + t.Fatal("full ACK did not enter publication wait") + } + select { + case <-joined: + t.Fatal("full ACK claimed completion before actual publication") + default: + } + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Fatal("published full ACK did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + require.Contains(t, toolResultText(result), `"ack_mode":"full"`) + }) + } +} diff --git a/internal/mcp/source_ack_policy.go b/internal/mcp/source_ack_policy.go new file mode 100644 index 000000000..2125423fb --- /dev/null +++ b/internal/mcp/source_ack_policy.go @@ -0,0 +1,73 @@ +package mcp + +import ( + "context" + "time" + + "github.com/zzet/gortex/internal/config" +) + +// A value snapshot belongs to the source operation, including every physical +// file and publication admission in a frontier. Configuration cannot change it +// between writes or while an admitted ticket is completing. +type sourceAckPolicy struct { + owner *Server + mode config.EditAckMode + freshness requestFreshness +} + +type sourceAckPolicyKey struct{} + +func (s *Server) sourceAckPolicy(ctx context.Context) sourceAckPolicy { + if policy, ok := ctx.Value(sourceAckPolicyKey{}).(sourceAckPolicy); ok && policy.owner == s { + return policy + } + mode := s.editAckMode + if mode == "" { + mode = config.EditAckFull + } + return sourceAckPolicy{owner: s, mode: mode} +} + +func (p sourceAckPolicy) local() bool { + return p.mode == config.EditAckLocal && !p.freshness.requireFresh +} + +// Explicit tier waits reuse the single freshness producer. They never turn a +// file/relations stamp into whole-patch completion, and own no source locks. +func (s *Server) awaitSourceAckTier(ctx context.Context, policy sourceAckPolicy, paths []string, published <-chan struct{}) error { + scope := policy.freshness.scope + if scope == "" { + scope = "file" + } + deadline := policy.freshness.effectiveDeadline(time.Now(), ctx) + waitCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + resumeIntent := suspendSourceMutationWriteIntent(ctx) + defer resumeIntent() + label := s.freshnessFor(waitCtx, scope, nil, paths) + if label.state == "" || label.state == "failed" || !deadline.After(time.Now()) { + return editGateCheckoutACKSeed(ctx, s) + } + lap := s.beginAcknowledgementLap(ctx, editAckReindexWait) + defer lap.finish() + if err := editGateACKSeed(ctx, s); err != nil { + return err + } + if !s.releaseDispatchSlotForWait(waitCtx, published) { + return nil + } + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + label = s.freshnessFor(waitCtx, scope, nil, paths) + if label.state == "" || label.state == "failed" { + return nil + } + select { + case <-waitCtx.Done(): + return nil + case <-ticker.C: + } + } +} diff --git a/internal/mcp/source_batch_frontier_wait_test.go b/internal/mcp/source_batch_frontier_wait_test.go new file mode 100644 index 000000000..4364c3083 --- /dev/null +++ b/internal/mcp/source_batch_frontier_wait_test.go @@ -0,0 +1,81 @@ +package mcp + +import ( + "context" + "os" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func TestDurableBatchReleasesWholeFrontierBeforePublicationWait(t *testing.T) { + published := make(chan indexer.MutationResult, 2) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)} + s := newAtomicBatchTestServer(t, watcher) + s.mutationReindexWait = time.Minute + root := t.TempDir() + paths := []string{writeAtomicBatchFixture(t, root, "a.txt", "before\n"), writeAtomicBatchFixture(t, root, "b.txt", "before\n")} + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + check, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPaths(check, paths) + if release != nil { + release() + } + waiting <- err + return true + } + complete := sync.OnceFunc(func() { + for range 2 { + published <- indexer.MutationResult{Reindexed: true} + } + close(published) + }) + joined := make(chan struct{}) + var receipt batchTransactionReceipt + var runErr error + go func() { + defer close(joined) + receipt, runErr = s.runBatchTransaction(t.Context(), []batchEditItem{atomicFileEdit(paths[0], "before", "after"), atomicFileEdit(paths[1], "before", "after")}, "unlocked-batch-frontier") + }() + defer func() { + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned durable batch did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.ElementsMatch(t, paths, admitted) + case <-time.After(3 * time.Second): + t.Fatal("durable batch did not admit its complete file set") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "after\n", string(raw)) + } + select { + case err := <-waiting: + require.NoError(t, err, "durable batch retained its held paths across graph publication wait") + case <-time.After(time.Second): + t.Fatal("durable batch did not enter its shared publication wait") + } + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Fatal("published durable batch did not join") + } + require.NoError(t, runErr) + require.Equal(t, "committed", receipt.Status) + require.Equal(t, "fresh", receipt.GraphStatus) + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) +} diff --git a/internal/mcp/source_bypass_commit_test.go b/internal/mcp/source_bypass_commit_test.go new file mode 100644 index 000000000..64b75de9c --- /dev/null +++ b/internal/mcp/source_bypass_commit_test.go @@ -0,0 +1,143 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/pathkey" +) + +func TestPropagatedSourceWriteHasCommitReceipt(t *testing.T) { + srv, _, root := setupDeleteServer(t) + path := filepath.Join(root, "caller.go") + ctx, note := withMutationCommitNote(t.Context()) + applied, failed, err := srv.applyRemoveLinePatches(ctx, []callerPatch{{ + File: "caller.go", Line: 4, CurrentText: "Target()", Action: "remove_line", abs: path, + }}) + require.NoError(t, err) + require.Empty(t, failed) + require.Len(t, applied, 1) + current, err := os.ReadFile(path) + require.NoError(t, err) + require.NotContains(t, string(current), "Target()", "prove that a real source write landed") + verdict := note.verdict() + require.Equal(t, mutationDiskCommitted, verdict.Status, "a source writer must publish the same disk receipt as the ordinary edit path") + require.Len(t, verdict.Snapshots, 1) + require.Equal(t, "caller.go", verdict.Snapshots[0].Path) + require.True(t, verdict.Snapshots[0].GraphRecorded) +} + +func TestOverlayDiskMergeUsesSourceCommitAuthority(t *testing.T) { + for _, deleted := range []bool{false, true} { + for _, cancelled := range []bool{false, true} { + t.Run(fmt.Sprintf("deleted_%t/cancelled_%t", deleted, cancelled), func(t *testing.T) { + srv, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + ctx, note := withMutationCommitNote(t.Context()) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + if cancelled { + cancel() + } + err = srv.diskWriteForMerge(ctx)(daemon.OverlayFile{Path: "main.go", Content: "package main\nfunc Merged() {}\n", Deleted: deleted, BaseSHA: gitBlobSHA(before)}) + if cancelled { + require.Error(t, err) + got, readErr := os.ReadFile(path) + require.NoError(t, readErr) + require.Equal(t, before, got, "refused merge must preserve bytes") + return + } + require.NoError(t, err) + if deleted { + _, statErr := os.Stat(path) + require.ErrorIs(t, statErr, os.ErrNotExist) + } else { + got, readErr := os.ReadFile(path) + require.NoError(t, readErr) + require.Contains(t, string(got), "func Merged()") + } + require.Equal(t, mutationDiskCommitted, note.verdict().Status) + require.Len(t, note.verdict().Snapshots, 1) + }) + } + } +} + +func TestMoveAndInlineSourceWritesHavePhysicalReceipts(t *testing.T) { + for _, tool := range []string{"move_symbol", "inline_symbol"} { + t.Run(tool, func(t *testing.T) { + srv, root := setupMoveInlineRepo(t, map[string]string{ + "pkg/accessor.go": "package pkg\nfunc Value() int { return 7 }\n", + "pkg/caller.go": "package pkg\nfunc Use() int { return Value() }\n", + "pkg/target.go": "package pkg\nfunc Existing() {}\n", + }) + ctx, note := withMutationCommitNote(t.Context()) + req := editFileRequest(map[string]any{"id": "pkg/accessor.go::Value", "target_file": "pkg/target.go"}) + var result *mcp.CallToolResult + var err error + if tool == "move_symbol" { + result, err = srv.handleMoveSymbol(ctx, req) + } else { + result, err = srv.handleInlineSymbol(ctx, req) + } + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + accessor, err := os.ReadFile(filepath.Join(root, "pkg/accessor.go")) + require.NoError(t, err) + require.NotContains(t, string(accessor), "func Value()", "prove a real multi-file source operation landed") + require.Equal(t, mutationDiskCommitted, note.verdict().Status) + paths := make([]string, 0, len(note.verdict().Snapshots)) + for _, snapshot := range note.verdict().Snapshots { + paths = append(paths, snapshot.Path) + require.True(t, snapshot.GraphRecorded) + } + want := []string{"pkg/accessor.go", "pkg/caller.go"} + if tool == "move_symbol" { + want = append(want, "pkg/target.go") + } + require.ElementsMatch(t, want, paths, "every actual source effect must own exactly one physical receipt") + }) + } +} + +func TestPlannedSourceAdmissionPreservesChangedAndNewTargets(t *testing.T) { + for _, kind := range []string{"changed", "new_target", "cancelled", "held"} { + t.Run(kind, func(t *testing.T) { + root := t.TempDir() + first, second := filepath.Join(root, "first.go"), filepath.Join(root, "second.go") + require.NoError(t, os.WriteFile(first, []byte("first"), 0o644)) + require.NoError(t, os.WriteFile(second, []byte("before"), 0o644)) + plans := []plannedSource{{path: first, before: []byte("first"), existed: true}, {path: second, before: []byte("before"), existed: kind != "new_target"}} + if kind == "changed" { + require.NoError(t, os.WriteFile(second, []byte("concurrent"), 0o644)) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if kind == "cancelled" { + cancel() + } + ctx, release, err := lockPlannedSources(ctx, plans) + if kind == "held" { + require.NoError(t, err) + require.True(t, mutationPathAdmitted(ctx, pathkey.MutationKey(first))) + require.True(t, mutationPathAdmitted(ctx, pathkey.MutationKey(second))) + release() + require.False(t, mutationPathAdmitted(ctx, pathkey.MutationKey(first))) + } else { + require.Error(t, err) + require.Nil(t, release) + } + got, readErr := os.ReadFile(first) + require.NoError(t, readErr) + require.Equal(t, "first", string(got), "failure on another planned path must precede every write") + }) + } +} diff --git a/internal/mcp/source_frontier_seed_test.go b/internal/mcp/source_frontier_seed_test.go new file mode 100644 index 000000000..ee3a4cef2 --- /dev/null +++ b/internal/mcp/source_frontier_seed_test.go @@ -0,0 +1,78 @@ +//go:build gortex_edit_gate_seed + +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func TestEditGateACKSeedCoversWholeFrontierOnceAfterUnlock(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "ack-wait") + s, root := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + published := make(chan indexer.MutationResult, 2) + for range 2 { + published <- indexer.MutationResult{Reindexed: true} + } + close(published) + s.SetWatcher(&renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + paths := []string{filepath.Join(root, "caller.go"), filepath.Join(root, "target.go")} + entered := make(chan error, 1) + var calls atomic.Int32 + editGateSeedACKEntered = func() { + calls.Add(1) + check, stop := context.WithTimeout(t.Context(), 200*time.Millisecond) + _, release, err := acquireMutationPaths(check, paths) + stop() + if release != nil { + release() + } + select { + case entered <- err: + default: + } + } + t.Cleanup(func() { editGateSeedACKEntered = nil }) + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "target.go::Target", "new_name": "Renamed", "format": "json"} + joined := make(chan struct{}) + go func() { defer close(joined); _, _ = s.runAcknowledgedEdit(ctx, request, s.handleRenameSymbol) }() + defer func() { + cancel() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned frontier seed did not join") + } + }() + select { + case err := <-entered: + require.NoError(t, err, "tag-only ACK seed retained physical path locks") + case <-joined: + t.Fatal("completed multi-file source frontier returned before ACK seed") + case <-time.After(3 * time.Second): + t.Fatal("multi-file source frontier never reached ACK seed") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(raw), "Renamed", "seed must follow every physical source effect") + } + cancel() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Fatal("cancelled frontier seed did not join") + } + require.EqualValues(t, 1, calls.Load(), "one operation cannot acquire a seed wait per file") +} diff --git a/internal/mcp/source_move_inline_frontier_test.go b/internal/mcp/source_move_inline_frontier_test.go new file mode 100644 index 000000000..c1da7ed9e --- /dev/null +++ b/internal/mcp/source_move_inline_frontier_test.go @@ -0,0 +1,166 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func TestMoveAndInlineAdmitWholeFrontierBeforeWaiting(t *testing.T) { + for _, tool := range []string{"move_symbol", "inline_symbol"} { + t.Run(tool, func(t *testing.T) { + files := map[string]string{"pkg/a.go": "package pkg\nfunc Foo() int { return 42 }\n", "pkg/b.go": "package pkg\nfunc Bar() int { return 1 }\n"} + args := map[string]any{"id": "pkg/a.go::Foo", "target_file": "pkg/b.go", "format": "json"} + if tool == "inline_symbol" { + files = map[string]string{"pkg/a.go": "package pkg\nfunc GetX(s *S) int { return s.X }\n", "pkg/b.go": "package pkg\nfunc Use() int { s := &S{X: 3}; return GetX(s) }\n", "pkg/types.go": "package pkg\ntype S struct { X int }\n"} + args = map[string]any{"id": "pkg/a.go::GetX", "format": "json"} + } + s, root := setupMoveInlineRepo(t, files) + s.ToolCallTimeout = -1 + s.mutationReindexWait = time.Minute + publications := make(chan indexer.MutationResult, 2) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + paths := []string{filepath.Join(root, "pkg", "a.go"), filepath.Join(root, "pkg", "b.go")} + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + check, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPaths(check, paths) + if err == nil { + release() + } + waiting <- err + return true + } + request := mcplib.CallToolRequest{} + request.Params.Name, request.Params.Arguments = tool, args + handler := s.boundToolHandler(func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + if tool == "move_symbol" { + return s.handleMoveSymbol(ctx, req) + } + return s.handleInlineSymbol(ctx, req) + }) + ctx, cancel := context.WithCancel(t.Context()) + finished := make(chan struct{}) + complete := sync.OnceFunc(func() { + for range 2 { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + } + close(publications) + }) + var result *mcplib.CallToolResult + var resultErr error + go func() { defer close(finished); result, resultErr = handler(ctx, request) }() + defer func() { + cancel() + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned mutation did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.ElementsMatch(t, paths, admitted) + case <-finished: + t.Fatalf("%s returned without admitting the committed source frontier", tool) + case <-time.After(3 * time.Second): + t.Fatal("mutation did not admit its committed source frontier") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.NotEqual(t, files["pkg/"+filepath.Base(path)], string(raw), "all physical source effects must precede admission") + } + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) + select { + case err := <-waiting: + require.NoError(t, err, "path set must be released before passive graph wait") + case <-time.After(time.Second): + t.Fatal("mutation never entered passive wait") + } + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("published mutation did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + if tool == "move_symbol" { + response := decodeFileOpsResult(t, result) + for _, raw := range response["file_observations"].([]any) { + id := raw.(map[string]any)["receipt"].(string) + status, ok := s.moveFileObservationPayload(ctx, id) + require.True(t, ok) + require.Equal(t, "admitted", status["requested_background_state"]) + require.Equal(t, "unavailable_queued_publication", status["direct_reconcile_state"]) + require.Nil(t, status["direct_reconcile"]) + require.Nil(t, status["read_assisted_reconcile"]) + require.Equal(t, map[string]string{"publish": "applicable", "reindex_wait": "applicable"}, status["acknowledgement_phase_applicability"]) + } + } + }) + } +} + +func TestInlineSameFileKeepsCallerRewriteWhenDeletingCallee(t *testing.T) { + s, root := setupMoveInlineRepo(t, map[string]string{ + "pkg/a.go": "package pkg\ntype S struct { X int }\nfunc GetX(s *S) int { return s.X }\nfunc Use() int { s := &S{X: 3}; return GetX(s) }\n", + }) + result := callTool(t, s, "inline_symbol", map[string]any{"id": "pkg/a.go::GetX", "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + raw, err := os.ReadFile(filepath.Join(root, "pkg", "a.go")) + require.NoError(t, err) + require.NotContains(t, string(raw), "func GetX") + require.NotContains(t, string(raw), "return GetX(s)", "deleting the callee must not overwrite the caller rewrite in the same physical file") + require.Contains(t, string(raw), "return s.X") +} + +func TestMovePartialIOFailureStillAdmitsCommittedTarget(t *testing.T) { + s, root := setupMoveInlineRepo(t, map[string]string{ + "pkg/a.go": "package pkg\nfunc Foo() int { return 42 }\n", + "pkg/b.go": "package pkg\nfunc Bar() int { return 1 }\n", + }) + publications := make(chan indexer.MutationResult) + defer close(publications) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Nanosecond + source := filepath.Join(root, "pkg", "a.go") + target := filepath.Join(root, "pkg", "b.go") + s.mutationPreCommitHook = func(record *mutationCommitRecord) { + if record.absPath == source { + // The target write already succeeded. Model a real independent + // directory replacement that makes the next atomic rename fail. + require.NoError(t, os.Remove(source)) + require.NoError(t, os.Mkdir(source, 0o755)) + } + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "pkg/a.go::Foo", "target_file": "pkg/b.go", "format": "json"} + result, err := s.handleMoveSymbol(t.Context(), request) + require.NoError(t, err) + require.True(t, result.IsError) + select { + case admitted := <-watcher.announced: + require.Equal(t, []string{target}, admitted, "already committed source effects retain publication even when the next file fails") + default: + t.Fatal("partial failure abandoned the successfully committed target") + } + raw, err := os.ReadFile(target) + require.NoError(t, err) + require.Contains(t, string(raw), "func Foo") + require.EqualValues(t, 1, watcher.points.Load()) + require.Zero(t, watcher.sets.Load()) +} diff --git a/internal/mcp/source_mutation_delta.go b/internal/mcp/source_mutation_delta.go new file mode 100644 index 000000000..98025fba5 --- /dev/null +++ b/internal/mcp/source_mutation_delta.go @@ -0,0 +1,193 @@ +package mcp + +import ( + "bytes" + "fmt" + "github.com/zzet/gortex/internal/graph" + "reflect" + "sort" + "strings" + "unicode/utf8" +) + +type fileSymbolDelta struct { + State string `json:"state"` + Reason string `json:"reason,omitempty"` + Counts *fileSymbolDeltaCounts `json:"counts"` + Symbols []fileSymbolDeltaEntry `json:"symbols"` + Truncated bool `json:"truncated"` +} +type fileSymbolDeltaCounts struct { + Before int `json:"before"` + After int `json:"after"` + Added int `json:"added"` + Removed int `json:"removed"` + Changed int `json:"changed"` + TotalChanges int `json:"total_changes"` +} +type fileSymbolDeltaEntry struct { + Change string `json:"change"` + ID string `json:"id"` + Kind graph.NodeKind `json:"kind"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + Signature string `json:"signature"` +} + +// buildFileSymbolDelta compares caller-owned complete symbol sets extracted from +// the supplied decoded bytes. baselineKnown is the caller's baseline authority; +// this pure helper cannot discover omitted nodes or authenticate an extraction. +// It validates the represented spans, but does not parse, read stores, or hash. +func buildFileSymbolDelta(before, after []byte, old, new []*graph.Node, baselineKnown bool) fileSymbolDelta { + unavailable := func(reason string) fileSymbolDelta { return fileSymbolDelta{State: "unavailable", Reason: reason} } + if !baselineKnown { + return unavailable("baseline_unknown") + } + previous, oldFile, err := fileSymbolSnapshots(before, old) + if err != nil { + return unavailable("before: " + err.Error()) + } + current, newFile, err := fileSymbolSnapshots(after, new) + if err != nil { + return unavailable("after: " + err.Error()) + } + if oldFile != "" && newFile != "" && oldFile != newFile { + return unavailable("mixed_file_evidence") + } + counts := &fileSymbolDeltaCounts{Before: len(previous), After: len(current)} + entries := make([]fileSymbolDeltaEntry, 0) + for id, next := range current { + prior, exists := previous[id] + change := "" + switch { + case !exists: + counts.Added++ + change = "added" + case !reflect.DeepEqual(prior.node, next.node) || !bytes.Equal(prior.body, next.body): + counts.Changed++ + change = "changed" + } + if change != "" { + entries = append(entries, fileSymbolPreview(next.node, change)) + } + } + for id, prior := range previous { + if _, exists := current[id]; !exists { + counts.Removed++ + entries = append(entries, fileSymbolPreview(prior.node, "removed")) + } + } + counts.TotalChanges = counts.Added + counts.Removed + counts.Changed + sort.Slice(entries, func(i, j int) bool { + if entries[i].ID != entries[j].ID { + return entries[i].ID < entries[j].ID + } + return entries[i].Change < entries[j].Change + }) + truncated := len(entries) > 20 + if truncated { + entries = entries[:20] + } + return fileSymbolDelta{State: "available", Counts: counts, Symbols: entries, Truncated: truncated} +} + +type fileSymbolSnapshot struct { + node *graph.Node + body []byte + start, end int +} + +func fileSymbolSnapshots(content []byte, nodes []*graph.Node) (map[string]fileSymbolSnapshot, string, error) { + if !utf8.Valid(content) || bytes.IndexByte(content, 0) >= 0 { + return nil, "", fmt.Errorf("source_not_decoded_utf8") + } + offsets := []int{0} + for i, c := range content { + if c == '\n' { + offsets = append(offsets, i+1) + } + } + lineEnd := func(line int) int { + if line < len(offsets) { + return offsets[line] - 1 + } + return len(content) + } + result := make(map[string]fileSymbolSnapshot, len(nodes)) + file := "" + spans := make([]fileSymbolSnapshot, 0, len(nodes)) + for _, node := range nodes { + if node == nil { + return nil, "", fmt.Errorf("nil_symbol") + } + if strings.TrimSpace(node.ID) == "" || node.Kind == "" || node.FilePath == "" { + return nil, "", fmt.Errorf("incomplete_symbol_identity") + } + if node.Stub || node.Origin != "" { + return nil, "", fmt.Errorf("nonlocal_symbol") + } + if _, exists := result[node.ID]; exists { + return nil, "", fmt.Errorf("duplicate_symbol_id") + } + if file == "" { + file = node.FilePath + } else if file != node.FilePath { + return nil, "", fmt.Errorf("mixed_file_evidence") + } + if signature, exists := node.Meta["signature"]; exists { + if _, ok := signature.(string); !ok { + return nil, "", fmt.Errorf("invalid_signature") + } + } + if node.StartLine < 1 || node.EndLine < node.StartLine || node.EndLine > len(offsets) { + return nil, "", fmt.Errorf("invalid_line_span") + } + start, end := offsets[node.StartLine-1], lineEnd(node.EndLine) + columns := node.StartColumn != 0 || node.EndColumn != 0 + if node.StartColumn < 0 || node.EndColumn < 0 { + return nil, "", fmt.Errorf("invalid_column_span") + } + if columns { + if node.StartColumn > lineEnd(node.StartLine)-start || node.EndColumn > end-offsets[node.EndLine-1] { + return nil, "", fmt.Errorf("invalid_column_span") + } + start += node.StartColumn + end = offsets[node.EndLine-1] + node.EndColumn + if start < len(content) && !utf8.RuneStart(content[start]) || end < len(content) && !utf8.RuneStart(content[end]) { + return nil, "", fmt.Errorf("column_splits_utf8") + } + } + if start >= end { + return nil, "", fmt.Errorf("empty_or_reversed_span") + } + snapshot := fileSymbolSnapshot{node: node, body: content[start:end], start: start, end: end} + spans = append(spans, snapshot) + result[node.ID] = snapshot + } + // Unknown columns mean a complete inclusive line range, not invented token + // offsets. Nested members can share their parent's lines. Equal or crossing + // ranges, even with columns, cannot establish distinct symbol ownership. + sort.Slice(spans, func(i, j int) bool { + if spans[i].start != spans[j].start { + return spans[i].start < spans[j].start + } + return spans[i].end > spans[j].end + }) + for i, span := range spans { + for _, other := range spans[i+1:] { + if other.start >= span.end { + break + } + strictlyContains := span.start <= other.start && span.end >= other.end && (span.start < other.start || span.end > other.end) + if !strictlyContains { + return nil, "", fmt.Errorf("ambiguous_symbol_spans") + } + } + } + return result, file, nil +} + +func fileSymbolPreview(node *graph.Node, change string) fileSymbolDeltaEntry { + signature, _ := node.Meta["signature"].(string) + return fileSymbolDeltaEntry{Change: change, ID: node.ID, Kind: node.Kind, StartLine: node.StartLine, EndLine: node.EndLine, Signature: signature} +} diff --git a/internal/mcp/source_mutation_delta_test.go b/internal/mcp/source_mutation_delta_test.go new file mode 100644 index 000000000..59a07ce07 --- /dev/null +++ b/internal/mcp/source_mutation_delta_test.go @@ -0,0 +1,208 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser/languages" + "reflect" + "testing" +) + +func deltaNode(id string, line int, signature string) *graph.Node { + return &graph.Node{ID: "x.go::" + id, Kind: graph.KindFunction, Name: id, FilePath: "x.go", StartLine: line, EndLine: line, Meta: map[string]any{"signature": signature}} +} +func TestFileSymbolDeltaActualBodyAndSignatureChanges(t *testing.T) { + before := []byte("func A() { return 1 }\nfunc Removed() {}\nfunc Sig(x int) {}\n") + after := []byte("func A() { return 2 }\nfunc Added() {}\nfunc Sig(x string) {}\n") + old := []*graph.Node{deltaNode("A", 1, "func A()"), deltaNode("Removed", 2, "func Removed()"), deltaNode("Sig", 3, "func Sig(x int)")} + new := []*graph.Node{deltaNode("Sig", 3, "func Sig(x string)"), deltaNode("Added", 2, "func Added()"), deltaNode("A", 1, "func A()")} + oldBytes, _ := json.Marshal(old) + newBytes, _ := json.Marshal(new) + bcopy := bytes.Clone(before) + acopy := bytes.Clone(after) + d := buildFileSymbolDelta(before, after, old, new, true) + want := &fileSymbolDeltaCounts{Before: 3, After: 3, Added: 1, Removed: 1, Changed: 2, TotalChanges: 4} + if d.State != "available" || !reflect.DeepEqual(d.Counts, want) { + t.Fatalf("missing actual source delta: %+v", d) + } + ids := []string{} + for _, s := range d.Symbols { + ids = append(ids, s.ID+":"+s.Change) + } + if !reflect.DeepEqual(ids, []string{"x.go::A:changed", "x.go::Added:added", "x.go::Removed:removed", "x.go::Sig:changed"}) { + t.Fatalf("unstable preview: %v", ids) + } + ob, _ := json.Marshal(old) + nb, _ := json.Marshal(new) + if !bytes.Equal(ob, oldBytes) || !bytes.Equal(nb, newBytes) || !bytes.Equal(before, bcopy) || !bytes.Equal(after, acopy) { + t.Fatal("mutated caller evidence") + } + new[0].Meta["signature"] = "mutated" + if d.Symbols[3].Signature != "func Sig(x string)" { + t.Fatal("preview aliases input metadata") + } +} +func TestFileSymbolDeltaCapDoesNotCapCounts(t *testing.T) { + var source bytes.Buffer + var nodes []*graph.Node + for i := 29; i >= 0; i-- { + fmt.Fprintln(&source, "func Symbol() {}") + nodes = append(nodes, deltaNode(fmt.Sprintf("S%02d", i), 30-i, "func Symbol()")) + } + d := buildFileSymbolDelta(nil, source.Bytes(), nil, nodes, true) + if d.Counts == nil || d.Counts.Added != 30 || d.Counts.TotalChanges != 30 || len(d.Symbols) != 20 || !d.Truncated { + t.Fatalf("preview lost full totals: %+v", d) + } + for i, s := range d.Symbols { + if s.ID != fmt.Sprintf("x.go::S%02d", i) { + t.Fatalf("not ID ordered: %+v", d.Symbols) + } + } + removed := buildFileSymbolDelta(source.Bytes(), nil, nodes, nil, true) + if removed.Counts == nil || removed.Counts.Removed != 30 || removed.Symbols[0].StartLine != 30 { + t.Fatalf("removed preview did not retain OLD: %+v", removed) + } +} +func TestFileSymbolDeltaUnavailableEvidence(t *testing.T) { + source := []byte("func A() {}\n") + for _, mode := range []string{"unknown", "nil", "duplicate", "mixed", "range", "columns", "signature", "invalid_utf8", "ambiguous"} { + t.Run(mode, func(t *testing.T) { + old := []*graph.Node{deltaNode("A", 1, "func A()")} + new := []*graph.Node{deltaNode("A", 1, "func A()")} + known := true + raw := bytes.Clone(source) + switch mode { + case "unknown": + known = false + case "nil": + new[0] = nil + case "duplicate": + new = append(new, new[0]) + case "mixed": + new[0].FilePath = "other.go" + case "range": + new[0].EndLine = 99 + case "columns": + new[0].EndColumn = 999 + case "signature": + new[0].Meta["signature"] = 12 + case "invalid_utf8": + raw = []byte{255} + case "ambiguous": + new = append(new, deltaNode("B", 1, "func B()")) + } + d := buildFileSymbolDelta(source, raw, old, new, known) + if d.State != "unavailable" || d.Counts != nil || d.Reason == "" || len(d.Symbols) != 0 { + t.Fatalf("fabricated evidence: %+v", d) + } + }) + } +} +func TestFileSymbolDeltaLineShiftIsChanged(t *testing.T) { + a := deltaNode("A", 1, "func A()") + b := deltaNode("A", 2, "func A()") + d := buildFileSymbolDelta([]byte("func A() {}\n"), []byte("\nfunc A() {}\n"), []*graph.Node{a}, []*graph.Node{b}, true) + if d.Counts == nil || d.Counts.Changed != 1 || d.Symbols[0].StartLine != 2 { + t.Fatalf("full node changes suppressed: %+v", d) + } + stable := buildFileSymbolDelta([]byte("func A() {}\n"), []byte("func A() {}\n"), []*graph.Node{a}, []*graph.Node{a}, true) + if stable.Counts == nil || stable.Counts.TotalChanges != 0 || len(stable.Symbols) != 0 { + t.Fatalf("unchanged input: %+v", stable) + } +} + +func TestFileSymbolDeltaPreciseColumnsAndUTF8(t *testing.T) { + left := deltaNode("Left", 1, "") + left.StartColumn = 0 + left.EndColumn = 3 + right := deltaNode("Right", 1, "") + right.StartColumn = 4 + right.EndColumn = 7 + d := buildFileSymbolDelta([]byte("abc def"), []byte("abc xyz"), []*graph.Node{left, right}, []*graph.Node{left, right}, true) + if d.Counts == nil || d.Counts.Changed != 1 || d.Symbols[0].ID != "x.go::Right" { + t.Fatalf("precise sibling ranges conflated: %+v", d) + } + split := deltaNode("UTF8", 1, "") + split.StartColumn = 1 + split.EndColumn = 2 + bad := buildFileSymbolDelta(nil, []byte("é"), nil, []*graph.Node{split}, true) + if bad.Counts != nil || bad.Reason == "" { + t.Fatalf("split Unicode rune accepted: %+v", bad) + } +} +func TestFileSymbolDeltaNestedAndCrossingUnknownColumns(t *testing.T) { + parent := deltaNode("Parent", 1, "") + parent.EndLine = 3 + child := deltaNode("Child", 2, "") + data := []byte("parent\nchild\nend\n") + d := buildFileSymbolDelta(data, data, []*graph.Node{parent, child}, []*graph.Node{child, parent}, true) + if d.Counts == nil || d.Counts.TotalChanges != 0 { + t.Fatalf("valid containment rejected: %+v", d) + } + crossing := deltaNode("Crossing", 2, "") + crossing.EndLine = 4 + data = []byte("parent\nchild\nend\ntail\n") + d = buildFileSymbolDelta(data, data, []*graph.Node{parent, crossing}, []*graph.Node{parent, crossing}, true) + if d.Counts != nil || d.Reason == "" { + t.Fatalf("crossing unknown spans accepted: %+v", d) + } +} +func TestFileSymbolDeltaUsesActualDecodedGoExtractorSpans(t *testing.T) { + before := []byte("package demo\nfunc A() int {\n return 1\n}\nfunc B() {}\n") + after := []byte("package demo\nfunc A() int {\n return 2\n}\nfunc B() {}\n") + extractor := languages.NewGoExtractor() + old, err := extractor.Extract("x.go", before) + if err != nil { + t.Fatal(err) + } + defer old.ReleaseTree() + new, err := extractor.Extract("x.go", after) + if err != nil { + t.Fatal(err) + } + defer new.ReleaseTree() + // Production caller owns symbol-set filtering; this fixture explicitly + // chooses the complete function set and keeps the actual extracted nodes. + symbols := func(nodes []*graph.Node) []*graph.Node { + var out []*graph.Node + for _, n := range nodes { + if n.Kind == graph.KindFunction { + out = append(out, n) + } + } + return out + } + d := buildFileSymbolDelta(before, after, symbols(old.Nodes), symbols(new.Nodes), true) + if d.Counts == nil || d.Counts.Before != 2 || d.Counts.After != 2 || d.Counts.Changed != 1 || len(d.Symbols) != 1 || d.Symbols[0].ID != "x.go::A" { + t.Fatalf("actual extractor body edit missed: %+v", d) + } +} +func TestFileSymbolDeltaFullNodeMetadataChanges(t *testing.T) { + old := deltaNode("A", 1, "") + new := deltaNode("A", 1, "") + new.Meta["visibility"] = "exported" + d := buildFileSymbolDelta([]byte("func A() {}"), []byte("func A() {}"), []*graph.Node{old}, []*graph.Node{new}, true) + if d.Counts == nil || d.Counts.Changed != 1 { + t.Fatalf("full node metadata difference suppressed: %+v", d) + } +} + +func TestFileSymbolDeltaPreciseRangesStillRejectAmbiguousOwnership(t *testing.T) { + for _, crossing := range []bool{false, true} { + left := deltaNode("Left", 1, "") + left.EndColumn = 7 + right := deltaNode("Right", 1, "") + right.EndColumn = 7 + if crossing { + left.EndColumn = 5 + right.StartColumn = 3 + right.EndColumn = 7 + } + d := buildFileSymbolDelta(nil, []byte("abcdefg"), nil, []*graph.Node{left, right}, true) + if d.Counts != nil || d.State != "unavailable" { + t.Fatalf("ambiguous precise ownership accepted crossing=%v: %+v", crossing, d) + } + } +} diff --git a/internal/mcp/source_mutation_frontier.go b/internal/mcp/source_mutation_frontier.go new file mode 100644 index 000000000..3c5a7d760 --- /dev/null +++ b/internal/mcp/source_mutation_frontier.go @@ -0,0 +1,229 @@ +package mcp + +import ( + "context" + "errors" + "path/filepath" + + "github.com/zzet/gortex/internal/pathkey" + + "github.com/zzet/gortex/internal/viewmetrics" +) + +// Capture the selected checkout's exact per-file write fact before a later +// commit updates the request's last-write state. No lease or path lock is new. +type sourceMutationTarget struct { + key string + path string + checkout *checkoutMutationState +} + +func captureSourceMutationTarget(ctx context.Context, path string) sourceMutationTarget { + target := sourceMutationTarget{path: path, key: pathkey.MutationKey(path)} + if state := checkoutMutationFromContext(ctx); state != nil { + copy := *state + target.checkout = © + } + return target +} + +// Admit the complete committed frontier while its original path set is held. +// A partial/error response cannot be retried pointwise: some files may already +// be admitted. Only the explicit nil-map/no-admission result permits fallback. +func (s *Server) admitSourceMutationFrontier(ctx context.Context, targets []sourceMutationTarget) []sourceMutationAdmission { + admissions := make([]sourceMutationAdmission, len(targets)) + policy := s.sourceAckPolicy(ctx) + defer func() { + for i := range admissions { + admissions[i].policy = policy + } + }() + if len(targets) > 1 && checkoutMutationFromContext(ctx) == nil { + if scheduler, ok := s.currentWatcher().(mutationSetScheduler); ok { + paths := make([]string, len(targets)) + for i, target := range targets { + paths[i] = target.path + } + lap := s.beginAcknowledgementLap(ctx, editAckTicketAdmission) + tickets, err := scheduler.EnqueueFileMutations(context.WithoutCancel(ctx), paths) + lap.finish() + if err == nil && tickets != nil { + for _, path := range paths { + ticket := tickets[filepath.Clean(path)] + if ticket == nil || ticket.Done == nil || filepath.Clean(ticket.Path) != filepath.Clean(path) { + err = errors.New("mutation set scheduler returned incomplete or mismatched coverage") + break + } + } + } + if err != nil { + for i := range admissions { + admissions[i].immediate.Err = err + } + return admissions + } + if tickets != nil { + for i, path := range paths { + ticket := tickets[filepath.Clean(path)] + pin := handoffRequestView(ctx, viewmetrics.HandoffFileMutation) + receipt := s.trackMutationTicket(ticket) + receipt.pinView(pin) + s.bindReadRepairAdmission(path, ticket.PublicationRecord, receipt) + admissions[i].receipt = receipt + } + return admissions + } + } + } + for i, target := range targets { + admissionCtx := ctx + if target.checkout != nil { + admissionCtx = context.WithValue(ctx, checkoutMutationContextKey{}, target.checkout) + } + admissions[i] = s.admitSourceMutations(admissionCtx, target.path) + } + return admissions +} + +// Call only after every source path has been released. Inline compatibility +// work completes before returning dispatch capacity; queued work shares one +// parent deadline, one dispatch handoff and the existing receipt identities. +func (s *Server) awaitSourceMutationFrontier(ctx context.Context, admissions []sourceMutationAdmission) []mutationReindexOutcome { + waitCtx, cancel := context.WithTimeout(ctx, s.mutationWaitDuration()) + defer cancel() + outcomes := make([]mutationReindexOutcome, len(admissions)) + var files []batchTransactionFile + for i, admission := range admissions { + if admission.receipt == nil { + if admission.inlinePath == "" && admission.checkout == nil { + outcomes[i] = admission.immediate + } else { + outcomes[i] = s.awaitSourceMutations(withoutDispatchSlotRelease(waitCtx), admission) + } + } else { + files = append(files, batchTransactionFile{Path: admission.receipt.path, ReindexReceipt: admission.receipt.id}) + } + } + if len(files) == 0 { + // A selected checkout fast-ACKs one admitted operation. Even when + // it touched many files, the tagged legacy regression waits once. + for _, outcome := range outcomes { + if outcome.Err == nil && outcome.Receipt != "" { + err := editGateCheckoutACKSeed(ctx, s) + if err != nil { + for i := range outcomes { + if outcomes[i].Receipt != "" { + outcomes[i].Err = err + } + } + } + break + } + } + return outcomes + } + resumeIntent := suspendSourceMutationWriteIntent(ctx) + defer resumeIntent() + if len(admissions) > 0 && admissions[0].policy.freshness.requireFresh { + paths := make([]string, 0, len(files)) + for _, file := range files { + paths = append(paths, file.Path) + } + err := s.awaitSourceAckTier(ctx, admissions[0].policy, paths, s.batchReceiptsPublished(ctx, files)) + for i, admission := range admissions { + if admission.receipt != nil { + outcomes[i] = admission.receipt.outcome(true) + if err != nil { + outcomes[i].Err = err + } + } + } + return outcomes + } + if len(admissions) > 0 && admissions[0].policy.local() { + for i, admission := range admissions { + if admission.receipt != nil { + outcomes[i] = admission.receipt.outcome(true) + } + } + if err := editGateCheckoutACKSeed(ctx, s); err != nil { + for i := range outcomes { + if admissions[i].receipt != nil { + outcomes[i].Err = err + } + } + } + return outcomes + } + lap := s.beginAcknowledgementLap(ctx, editAckReindexWait) + if err := editGateACKSeed(ctx, s); err != nil { + lap.finish() + for i, admission := range admissions { + if admission.receipt != nil { + outcomes[i] = admission.receipt.outcome(true) + outcomes[i].Err = err + } + } + return outcomes + } + + if s.releaseDispatchSlotForWait(waitCtx, s.batchReceiptsPublished(waitCtx, files)) { + wait: + for _, admission := range admissions { + if admission.receipt != nil { + select { + case <-admission.receipt.done: + case <-waitCtx.Done(): + break wait + } + } + } + } + lap.finish() + for i, admission := range admissions { + if admission.receipt != nil { + outcomes[i] = admission.receipt.outcome(true) + if !outcomes[i].Pending { + outcomes[i].Receipt = "" + } + } + } + return outcomes +} + +// An operation may rewrite one file more than once (propagated callsites then +// a definition). Only its final physical version can own a publication ticket. +// Intermediate physical receipts remain historical, without invented graph +// success for bytes no publication actually read. +type committedSourceMutation struct { + target sourceMutationTarget + record *mutationCommitRecord +} + +func captureCommittedSourceMutation(ctx context.Context, path string, record *mutationCommitRecord) committedSourceMutation { + return committedSourceMutation{target: captureSourceMutationTarget(ctx, path), record: record} +} + +func (s *Server) completeCommittedSourceMutations(ctx context.Context, writes []committedSourceMutation, releasePaths func()) ([]committedSourceMutation, []mutationReindexOutcome) { + final := make([]committedSourceMutation, 0, len(writes)) + positions := make(map[string]int, len(writes)) + for _, write := range writes { + if at, ok := positions[write.target.key]; ok { + final[at] = write + } else { + positions[write.target.key] = len(final) + final = append(final, write) + } + } + targets := make([]sourceMutationTarget, len(final)) + for i, write := range final { + targets[i] = write.target + } + admissions := s.admitSourceMutationFrontier(ctx, targets) + releasePaths() + outcomes := s.awaitSourceMutationFrontier(ctx, admissions) + for i, outcome := range outcomes { + final[i].record.recordGraph(outcome) + } + return final, outcomes +} diff --git a/internal/mcp/source_mutation_frontier_test.go b/internal/mcp/source_mutation_frontier_test.go new file mode 100644 index 000000000..baf2055aa --- /dev/null +++ b/internal/mcp/source_mutation_frontier_test.go @@ -0,0 +1,90 @@ +package mcp + +import ( + "context" + "path/filepath" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +type sourceFrontierCoverageWatcher struct { + mutationTestWatcher + partial bool + points int +} + +func (w *sourceFrontierCoverageWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + w.points++ + return w.mutationTestWatcher.EnqueueToolMutation(ctx, path) +} + +func (w *sourceFrontierCoverageWatcher) EnqueueFileMutations(ctx context.Context, paths []string) (map[string]*indexer.MutationTicket, error) { + if !w.partial { + return nil, nil // Explicitly no file was admitted. + } + ticket, err := w.mutationTestWatcher.EnqueueToolMutation(ctx, paths[0]) + return map[string]*indexer.MutationTicket{paths[0]: ticket}, err +} + +func TestSourceMutationFrontierNeverDuplicatesPartialAdmission(t *testing.T) { + s := newMutationServer() + watcher := &sourceFrontierCoverageWatcher{partial: true} + s.SetWatcher(watcher) + root := t.TempDir() + targets := []sourceMutationTarget{{path: filepath.Join(root, "a.go")}, {path: filepath.Join(root, "b.go")}} + admissions := s.admitSourceMutationFrontier(t.Context(), targets) + require.Len(t, admissions, 2) + for _, admission := range admissions { + require.ErrorContains(t, admission.immediate.Err, "coverage") + require.Nil(t, admission.receipt) + require.Empty(t, admission.inlinePath) + } + require.Zero(t, watcher.points, "an incomplete admitted frontier must never be admitted again pointwise") +} + +func TestSourceMutationFrontierNilSetFallsBackOnce(t *testing.T) { + s := newMutationServer() + watcher := &sourceFrontierCoverageWatcher{} + s.SetWatcher(watcher) + root := t.TempDir() + targets := []sourceMutationTarget{{path: filepath.Join(root, "a.go")}, {path: filepath.Join(root, "b.go")}} + admissions := s.admitSourceMutationFrontier(t.Context(), targets) + require.Len(t, admissions, 2) + for _, admission := range admissions { + require.NotNil(t, admission.receipt) + <-admission.receipt.done + } + require.Equal(t, 2, watcher.points) +} + +func TestSourceMutationFrontierSharesDeadlineAndOneDispatchHandoff(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s := newMutationServer() + s.mutationReindexWait = 100 * time.Millisecond + first := &mutationReceipt{id: "first", done: make(chan struct{})} + second := &mutationReceipt{id: "second", done: make(chan struct{})} + s.mutationReceipts.Store(first.id, first) + s.mutationReceipts.Store(second.id, second) + calls := 0 + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { calls++; return true } + time.AfterFunc(60*time.Millisecond, func() { + first.mu.Lock() + first.completed = true + first.result = indexer.MutationResult{Reindexed: true} + first.mu.Unlock() + close(first.done) + }) + start := time.Now() + outcomes := s.awaitSourceMutationFrontier(t.Context(), []sourceMutationAdmission{{receipt: first}, {receipt: second}}) + require.Equal(t, 100*time.Millisecond, time.Since(start), "the second file must use the original deadline, not another wait allowance") + require.Equal(t, 1, calls) + require.True(t, outcomes[0].Reindexed) + require.False(t, outcomes[0].Pending) + require.True(t, outcomes[1].Pending) + require.Equal(t, second.id, outcomes[1].Receipt) + }) +} diff --git a/internal/mcp/source_parse_clock_precision_test.go b/internal/mcp/source_parse_clock_precision_test.go new file mode 100644 index 000000000..b8922c437 --- /dev/null +++ b/internal/mcp/source_parse_clock_precision_test.go @@ -0,0 +1,42 @@ +package mcp + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestMeasuredParseEqualClockTickIsNotMissingOrReversed(t *testing.T) { + at := time.Now() + for _, kind := range []string{"completed_equal", "missing", "reversed"} { + t.Run(kind, func(t *testing.T) { + ack := &editAcknowledgement{entered: at} + if kind != "missing" { + begin := at + if kind == "reversed" { + begin = at.Add(time.Millisecond) + } + lap := ack.beginLapAt(editAckParseGate, begin) + lap.finishAt(at) + } + ack.finishActualAt(at) + ack.finishReplyAt(at, "result") + snapshot := ack.snapshot() + payload := buildAcknowledgementPayload(&snapshot, "observed") + parse := payload.ObservedExecutionMS["parse"] + if kind == "completed_equal" { + require.Equal(t, "measured", payload.PhaseStates["parse"]) + require.NotNil(t, parse) + require.Zero(t, *parse) + require.EqualValues(t, 1, payload.Counts["parse"].Started) + require.EqualValues(t, 1, payload.Counts["parse"].Completed) + require.Zero(t, payload.Counts["parse"].NotApplicable) + require.True(t, payload.Disjoint) + } else { + require.Nil(t, parse, "no observed parse or a reversed interval cannot acquire zero-duration credit") + require.NotEqual(t, "measured", payload.PhaseStates["parse"]) + } + }) + } +} diff --git a/internal/mcp/source_projection_ack_suspect_test.go b/internal/mcp/source_projection_ack_suspect_test.go new file mode 100644 index 000000000..ebf26284d --- /dev/null +++ b/internal/mcp/source_projection_ack_suspect_test.go @@ -0,0 +1,102 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func committedProjectionAckFixture(t *testing.T) (string, *projectionEntry, *mutationCommitRecord) { + t.Helper() + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + done := make(chan indexer.MutationResult, 1) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + s.mutationReindexWait = time.Nanosecond + path := filepath.Join(root, "main.go") + result := callTool(t, s, "edit_file", map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc SameOne() {}", "format": "json"}) + require.False(t, result.IsError) + response := extractTextResult(t, result) + requireMutationFileViewState(t, response, "projected") + entry := liveEntry(pendingFileProjections, path) + require.NotNil(t, entry) + record, ok := s.mutationCommits.byReceipt(response["mutation_receipt"].(string)) + require.True(t, ok) + return path, entry, record +} + +func TestMutationAckSuspectCheckPreservesNextLookupInvalidation(t *testing.T) { + path, entry, record := committedProjectionAckFixture(t) + before, err := os.ReadFile(path) + require.NoError(t, err) + changed := strings.Replace(string(before), "SameOne", "SameTwo", 1) + require.NotEqual(t, string(before), changed) + require.Len(t, changed, len(before)) + require.NoError(t, os.WriteFile(path, []byte(changed), 0o644)) + require.NoError(t, os.Chtimes(path, entry.disk.info.ModTime(), entry.disk.info.ModTime())) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + require.True(t, entry.disk.matches(stamp), "the external change must defeat the stat fast path") + pendingFileProjections.markSuspect(path) + response := make(map[string]any) + attachMutationCommit(response, record) + requireMutationFileViewState(t, response, "unavailable") + suspectAfterAck := entry.suspect.Load() + next, lookupErr := pendingFileProjections.lookup(path) + assert.ErrorIs(t, lookupErr, errProjectionExternalWrite, "an ACK check must not consume the only invalidation witness") + assert.Nil(t, next, "the next read must never receive the stale SameOne graph") + assert.True(t, suspectAfterAck, "receipt rendering must leave hash revalidation to the real lookup") +} + +func TestMutationAckRejectsSuspectReplacementWithoutFollowingIt(t *testing.T) { + for _, kind := range []string{"large", "symlink", "directory"} { + t.Run(kind, func(t *testing.T) { + path, entry, record := committedProjectionAckFixture(t) + switch kind { + case "large": + require.NoError(t, os.WriteFile(path, make([]byte, (1<<20)+1), 0o644)) + case "directory": + require.NoError(t, os.Remove(path)) + require.NoError(t, os.Mkdir(path, 0o755)) + case "symlink": + other := filepath.Join(filepath.Dir(path), "other.go") + require.NoError(t, os.WriteFile(other, []byte("private replacement must not be read"), 0o644)) + require.NoError(t, os.Remove(path)) + if err := os.Symlink(other, path); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } + pendingFileProjections.markSuspect(path) + response := make(map[string]any) + attachMutationCommit(response, record) + requireMutationFileViewState(t, response, "unavailable") + require.Equal(t, false, response["readback_exact"]) + require.True(t, entry.suspect.Load(), "an ACK may stat/refuse, but it must not read or clear a suspect file") + require.Same(t, entry, liveEntry(pendingFileProjections, path), "receipt rendering must not alter projection ownership") + }) + } +} + +func TestMutationAckCannotCreditReplacedEntryWithMatchingDiskStamp(t *testing.T) { + _, entry, record := committedProjectionAckFixture(t) + // A modeled COW replacement shares immutable content and disk metadata; + // pointer ownership, rather than equal bytes or sequence arithmetic, fences + // the old receipt. This is not a second physical write or timing sample. + replacement := entry.committedCopy(entry.commitSeq+1, entry.disk) + require.NoError(t, pendingFileProjections.publish(replacement)) + response := make(map[string]any) + attachMutationCommit(response, record) + requireMutationFileViewState(t, response, "unavailable") + require.Equal(t, false, response["readback_exact"]) + require.Same(t, replacement, liveEntry(pendingFileProjections, entry.absPath)) + require.False(t, replacement.suspect.Load()) +} diff --git a/internal/mcp/source_projection_admission_test.go b/internal/mcp/source_projection_admission_test.go new file mode 100644 index 000000000..ca4590ba5 --- /dev/null +++ b/internal/mcp/source_projection_admission_test.go @@ -0,0 +1,180 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +func TestToolCommitPublishesExactProjectionBeforeGraphCompletion(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + done := make(chan indexer.MutationResult, 1) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + s.mutationReindexWait = time.Nanosecond + path := filepath.Join(root, "main.go") + before := parser.ParseCount() + result := callTool(t, s, "edit_file", map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc ProjectedAtCommit() {}", "format": "json"}) + parses := parser.ParseCount() - before + require.False(t, result.IsError, "%v", result.Content) + payload := extractTextResult(t, result) + require.Equal(t, true, payload["reindex_pending"]) + require.Equal(t, map[string]any{"file": "fresh"}, payload["tier_state"], "the physical projection supplies file exactness, but an unstamped ticket cannot fabricate graph tier evidence") + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(raw), "ProjectedAtCommit") + entry, err := pendingFileProjections.lookup(path) + require.NoError(t, err) + require.NotNil(t, entry, "physical commit must admit its exact file projection before a passive graph wait can end") + require.Positive(t, entry.commitSeq) + require.EqualValues(t, 1, parses, "a clean committed candidate shares one native parse with its syntax gate") + require.NotNil(t, graph.NewOverlaidView(s.graph, entry.layer).GetNode("main.go::ProjectedAtCommit")) + require.Nil(t, s.graph.GetNode("main.go::ProjectedAtCommit"), "file projection is not graph publication") + requireMutationFileViewState(t, payload, "projected") + require.Equal(t, true, payload["readback_exact"]) +} + +type sourceProjectionBlockingExtractor struct { + base parser.Extractor + active atomic.Bool + once sync.Once + entered, release, finished chan struct{} +} + +func (e *sourceProjectionBlockingExtractor) Language() string { return "go" } +func (e *sourceProjectionBlockingExtractor) Extensions() []string { return []string{".go"} } +func (e *sourceProjectionBlockingExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult, error) { + if e.active.Load() { + e.once.Do(func() { close(e.entered) }) + <-e.release + defer close(e.finished) + } + return e.base.Extract(path, raw) +} + +func TestMutationCommitRechecksCancellationAndIdentityAfterProjection(t *testing.T) { + for _, mode := range []string{"cancel", "retarget"} { + t.Run(mode, func(t *testing.T) { + blocker := &sourceProjectionBlockingExtractor{base: languages.NewGoExtractor(), entered: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{})} + registry := parser.NewRegistry() + registry.Register(blocker) + s, root := setupTestServerWithRegistry(t, registry) + var unblock sync.Once + t.Cleanup(func() { unblock.Do(func() { close(blocker.release) }) }) + path := filepath.Join(root, "main.go") + var alias, other string + if mode == "retarget" { + original := filepath.Join(root, "original") + other = filepath.Join(root, "other") + alias = filepath.Join(root, "selected") + for _, dir := range []string{original, other} { + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package p\nfunc Untouched() {}\n"), 0o644)) + } + if err := os.Symlink(original, alias); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + path = filepath.Join(alias, "main.go") + } + before, err := os.ReadFile(path) + require.NoError(t, err) + parent, cancel := context.WithCancel(t.Context()) + defer cancel() + ctx, release, err := acquireMutationPath(parent, path) + require.NoError(t, err) + defer release() + blocker.active.Store(true) + done := make(chan error, 1) + go func() { + _, err := s.commitFileMutation(ctx, "write_file", "", "", "main.go", path, []byte("package p\nfunc BlockedProjection() {}\n"), 0o644) + done <- err + }() + select { + case <-blocker.entered: + case <-time.After(2 * time.Second): + t.Fatal("candidate projection did not enter") + } + if mode == "cancel" { + cancel() + } else { + require.NoError(t, os.Remove(alias)) + require.NoError(t, os.Symlink(other, alias)) + } + unblock.Do(func() { close(blocker.release) }) + select { + case err := <-done: + require.ErrorIs(t, err, errMutationNotApplied) + case <-time.After(2 * time.Second): + t.Fatal("refused source commit did not return") + } + select { + case <-blocker.finished: + case <-time.After(2 * time.Second): + t.Fatal("owned projection extraction did not finish") + } + current, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, current, "no bytes may change after cancellation or held-path identity loss during preparation") + }) + } +} + +func TestRejectedProjectionCannotEscapeThroughMutationLedger(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + pendingFileProjections.enabled = false + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + s.SetWatcher(mutationTestWatcher{}) + result := callTool(t, s, "edit_file", map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { _ = 2 }", "format": "json"}) + require.False(t, result.IsError) + payload := extractTextResult(t, result) + requireMutationFileViewState(t, payload, "unavailable") + require.Equal(t, false, payload["readback_exact"]) + require.Equal(t, "disabled", payload["file_reason"]) + require.Empty(t, pendingFileProjections.snap.Load().entries) + record, ok := s.mutationCommits.byReceipt(payload["mutation_receipt"].(string)) + require.True(t, ok) + require.Nil(t, record.fileProjection.Value(), "a refused graph must never be retained by a physical receipt") +} + +func TestPreparedProjectionEachPhysicalCommitHasImmutableEntry(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + raw := []byte("package main\nfunc SameBytes() {}\n") + prepared, _ := s.buildFileProjection(ctx, path, raw) + require.NotNil(t, prepared.entry, prepared.refusal) + operation := sourceMutationOperation{kind: sourceWrite, projection: prepared} + _, err = s.commitFileMutation(ctx, "write_file", "", "", "main.go", path, raw, 0o644, operation) + require.NoError(t, err) + first := liveEntry(pendingFileProjections, path) + require.NotNil(t, first) + sequence, disk := first.commitSeq, first.disk + _, err = s.commitFileMutation(ctx, "write_file", "", "", "main.go", path, raw, 0o644, operation) + require.NoError(t, err) + second := liveEntry(pendingFileProjections, path) + require.NotSame(t, first, second) + require.Greater(t, second.commitSeq, sequence) + require.Equal(t, sequence, first.commitSeq, "old pinned snapshots must remain immutable") + require.True(t, first.disk.matches(disk)) + require.Zero(t, prepared.entry.commitSeq, "a reusable private preparation never becomes a published mutable entry") +} diff --git a/internal/mcp/source_projection_commit.go b/internal/mcp/source_projection_commit.go new file mode 100644 index 000000000..a44648b2d --- /dev/null +++ b/internal/mcp/source_projection_commit.go @@ -0,0 +1,137 @@ +package mcp + +import ( + "context" + "path/filepath" + "weak" + + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/indexer/source" + "github.com/zzet/gortex/internal/pathkey" +) + +// Only the bounded registry strongly owns accepted file graphs. A physical +// receipt retains a weak link and scalars, so rejected/retired projections +// cannot escape the registry's global memory allowance through the ledger. +func (s *Server) publishCommittedFileProjection(ctx context.Context, record *mutationCommitRecord, prepared *preparedFileProjection) { + if prepared == nil { + return + } + reason := prepared.refusal + entry := prepared.entry + exact := false + if entry != nil { + reason = "readback_unavailable" + if pathkey.MutationKey(entry.absPath) == prepared.key { + before, err := statDiskStamp(entry.absPath) + if entry.tombstone { + // Absence is exact readback of this held-path unlink, not proof + // that any background generation has published the deletion. + if err == nil && before.info == nil && pathkey.MutationKey(entry.absPath) == prepared.key { + exact = true + reason = publishPhysicalProjection(record, entry, before) + } + } else if err == nil && before.info != nil && before.info.Mode().IsRegular() { + files, err := source.NewFilesystemSource(filepath.Dir(entry.absPath)) + if err == nil { + raw, _, readErr := files.ReadRegularFile(ctx, filepath.Base(entry.absPath), 1<<20) + files.Close() + after, statErr := statDiskStamp(entry.absPath) + if readErr == nil && statErr == nil && before.matches(after) && blake3.Sum256(raw) == prepared.rawHash && pathkey.MutationKey(entry.absPath) == prepared.key { + exact = true + reason = publishPhysicalProjection(record, entry, after) + } else if readErr == nil { + reason = "readback_changed" + } + } + } + } else { + reason = "source_identity_changed" + } + } + record.mu.Lock() + record.fileReason = reason + record.readbackExact = exact + record.mu.Unlock() +} + +func (record *mutationCommitRecord) attachFileProjection(response map[string]any) { + record.mu.RLock() + sequence, committed := record.commitSeq, record.disk == mutationDiskCommitted + contentSHA256 := record.contentSHA256 + entry, reason, exact := record.fileProjection.Value(), record.fileReason, record.readbackExact + record.mu.RUnlock() + if !committed { + return + } + response["commit_seq"] = sequence + if contentSHA256 != "" { + response["content_sha256"] = contentSHA256 + } + // The original write receipt is historical; a newer registry entry must + // never be revived or advertised as this receipt's live file projection. + if liveProjectionForAcknowledgement(entry) { + response["file_view"] = fileViewForEntry(entry) + response["readback_exact"] = exact + } else { + if reason == "" { + reason = "projection_not_live" + } + response["file_view"] = mutationFileView{State: "unavailable", Reason: reason, Historical: exact, DeltaState: "unavailable", DeltaReason: "projection_not_live", SyntaxHealth: fileViewSyntaxHealth{State: "unknown"}} + response["readback_exact"] = false + } + attachMutationFileTier(response, response["readback_exact"] == true) + response["graph_status_hint"] = mutationFileGraphHint(response, response["readback_exact"] == true) + if reason != "" { + response["file_reason"] = reason + } +} + +// Receipt formatting cannot consume invalidation or perform a hash read. +// Suspect bytes belong to the bounded read-through/lookup revalidation path. +// Check the immutable pointer on both sides of metadata I/O so an old receipt +// cannot claim a concurrently replaced entry, even with identical bytes. +func liveProjectionForAcknowledgement(entry *projectionEntry) bool { + if entry == nil || entry.suspect.Load() || pendingFileProjections.snap.Load().entries[entry.key] != entry { + return false + } + stamp, err := statDiskStamp(entry.absPath) + if err != nil || !entry.disk.matches(stamp) { + return false + } + if entry.tombstone { + if stamp.info != nil { + return false + } + } else if stamp.info == nil || !stamp.info.Mode().IsRegular() { + return false + } + return !entry.suspect.Load() && pendingFileProjections.snap.Load().entries[entry.key] == entry +} + +// Bind accepted file facts to exactly this physical execution. Tombstones +// carry no syntax verdict because no candidate source bytes were parsed. +func publishPhysicalProjection(record *mutationCommitRecord, entry *projectionEntry, stamp diskStamp) string { + record.mu.RLock() + sequence := record.commitSeq + record.mu.RUnlock() + entry = entry.committedCopy(sequence, stamp) + if err := pendingFileProjections.publish(entry); err != nil { + if refusal, ok := err.(projectionRefusal); ok { + return string(refusal) + } + return "projection_refused" + } + record.mu.Lock() + record.fileProjection = weak.Make(entry) + record.mu.Unlock() + if !entry.tombstone { + if !entry.parseChecked { + return "syntax_unknown" + } + if entry.parseErrors > 0 { + return "parse_errors" + } + } + return "" +} diff --git a/internal/mcp/source_projection_file_view.go b/internal/mcp/source_projection_file_view.go new file mode 100644 index 000000000..0285efd14 --- /dev/null +++ b/internal/mcp/source_projection_file_view.go @@ -0,0 +1,265 @@ +package mcp + +import ( + "context" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + "unsafe" + + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/indexer/source" + "github.com/zzet/gortex/internal/pathkey" +) + +// This bounded immutable evidence lives only inside the existing projection +// registry. Physical receipts and replay caches must not retain its strings. +type fileViewEvidence struct { + Delta fileSymbolDelta + Syntax fileViewSyntaxHealth +} + +type fileViewSyntaxHealth struct { + State string `json:"state"` + Checked bool `json:"checked"` + Errors *int `json:"errors"` + Language string `json:"language,omitempty"` +} + +type mutationFileView struct { + State string `json:"state"` + Reason string `json:"reason,omitempty"` + Historical bool `json:"historical,omitempty"` + DeltaState string `json:"delta_state"` + DeltaReason string `json:"delta_reason,omitempty"` + Counts *fileSymbolDeltaCounts `json:"counts"` + Symbols []fileSymbolDeltaEntry `json:"symbols"` + Truncated bool `json:"truncated"` + SyntaxHealth fileViewSyntaxHealth `json:"syntax_health"` +} + +func projectionSyntaxHealth(entry *projectionEntry) fileViewSyntaxHealth { + health := fileViewSyntaxHealth{State: "unknown"} + if entry == nil { + return health + } + health.Language = entry.language + if entry.tombstone { + health.State = "not_applicable_removed" + return health + } + if entry.parseChecked && entry.parseErrors >= 0 && entry.hash == hex.EncodeToString(entry.rawHash[:]) { + errors := entry.parseErrors + health.State = "checked" + health.Checked = true + health.Errors = &errors + } + return health +} + +func (s *Server) prepareFileViewEvidence(ctx context.Context, prepared *preparedFileProjection, before *plannedSource, after []byte) { + if prepared == nil || prepared.entry == nil { + return + } + entry := prepared.entry + evidence := &fileViewEvidence{Syntax: projectionSyntaxHealth(entry)} + if !entry.tombstone && entry.hash != hex.EncodeToString(entry.rawHash[:]) { + evidence.Delta = fileSymbolDelta{State: "unavailable", Reason: "transformed_source"} + } else { + budget, cancel := context.WithTimeout(ctx, 30*time.Millisecond) + old, known, reason := s.fileViewBaseline(budget, prepared, before) + if before == nil { + before = &plannedSource{} + } + evidence.Delta = buildFileSymbolDelta(indexer.DecodeUTF16Source(before.before), indexer.DecodeUTF16Source(after), old, fileViewSymbols(entry.layer.FileNodes(entry.graphPath)), known) + if !known && reason != "" { + evidence.Delta.Reason = reason + } + if budget.Err() != nil { + evidence.Delta = fileSymbolDelta{State: "unavailable", Reason: "baseline_deadline"} + } + cancel() + } + // Tight copies prevent the display slice or a substring from retaining + // undisplayed changes or an entire older source outside the byte allowance. + evidence.Delta.Symbols = slices.Clone(evidence.Delta.Symbols) + for i := range evidence.Delta.Symbols { + row := &evidence.Delta.Symbols[i] + row.ID = strings.Clone(row.ID) + row.Kind = graph.NodeKind(strings.Clone(string(row.Kind))) + row.Signature = strings.Clone(row.Signature) + } + entry.fileView = evidence + entry.size += fileViewEvidenceCharge(evidence) +} + +func fileViewSymbols(nodes []*graph.Node) []*graph.Node { + symbols := make([]*graph.Node, 0, len(nodes)) + for _, node := range nodes { + if node == nil { + symbols = append(symbols, nil) + continue + } + switch node.Kind { + case graph.KindFile, graph.KindPackage, graph.KindImport: + continue + } + symbols = append(symbols, node) + } + return symbols +} + +// Authenticate a complete prior symbol set against the very same selected +// reader and the caller's held-path byte snapshot. No canonical fallback can +// fill a missing selected capability, inventory row, or newer pointer. +func (s *Server) fileViewBaseline(ctx context.Context, prepared *preparedFileProjection, before *plannedSource) ([]*graph.Node, bool, string) { + entry := prepared.entry + if before == nil || pathkey.MutationKey(before.path) != prepared.key || !mutationPathAdmitted(ctx, prepared.key) { + return nil, false, "baseline_unknown" + } + if !before.existed { + stamp, err := statDiskStamp(entry.absPath) + if err == nil && stamp.info == nil && pendingFileProjections.snap.Load().entries[prepared.key] == prepared.expected { + return nil, true, "" + } + return nil, false, "absence_not_current" + } + rawHash := blake3.Sum256(before.before) + prior := prepared.expected + if prior != nil && !prior.tombstone && prior.graphPath == entry.graphPath && prior.repoPrefix == entry.repoPrefix && prior.language == entry.language && prior.rawHash == rawHash && !prior.suspect.Load() && pendingFileProjections.snap.Load().entries[prepared.key] == prior { + nodes := fileViewSymbols(prior.layer.FileNodes(prior.graphPath)) + if !prior.suspect.Load() && pendingFileProjections.snap.Load().entries[prepared.key] == prior { + return nodes, true, "" + } + return nil, false, "baseline_replaced" + } + reader := s.readerFor(ctx) + inventory, ok := reader.(graph.ConstantValueProjectionReader) + if !ok { + return nil, false, "baseline_metadata_unsupported" + } + key := graph.ConstantFileKey{RepoPrefix: entry.repoPrefix, FilePath: entry.graphPath} + read := func() (graph.FileMetaRow, bool) { + rows, err := inventory.ReadConstantValueProjectionContext(ctx, nil, []graph.ConstantFileKey{key}) + if err != nil { + return graph.FileMetaRow{}, false + } + row, found := rows.Files[key] + return row, found + } + first, found := read() + if !found || first.ContentHash == "" || first.ContentHash != hex.EncodeToString(rawHash[:]) { + return nil, false, "baseline_bytes_not_accepted" + } + rows, err := graph.ContractFileNodesContext(ctx, reader, entry.repoPrefix, []string{entry.graphPath}) + if err != nil { + return nil, false, "baseline_nodes_unavailable" + } + last, found := read() + if !found || first != last || ctx.Err() != nil || pendingFileProjections.snap.Load().entries[prepared.key] != prepared.expected { + return nil, false, "baseline_changed" + } + nodes, found := rows[entry.graphPath] + if !found || len(nodes) != first.NodeCount { + return nil, false, "baseline_nodes_incomplete" + } + return fileViewSymbols(nodes), true, "" +} + +func fileViewEvidenceCharge(e *fileViewEvidence) int64 { + if e == nil { + return 0 + } + // Round each separately owned allocation conservatively, including headers. + allocation := func(n int64) int64 { + if n <= 0 { + return 0 + } + return ((n+15)/16)*16 + 64 + } + size := allocation(int64(unsafe.Sizeof(*e))) + allocation(int64(cap(e.Delta.Symbols))*int64(unsafe.Sizeof(fileSymbolDeltaEntry{}))) + if e.Delta.Counts != nil { + size += allocation(int64(unsafe.Sizeof(*e.Delta.Counts))) + } + if e.Syntax.Errors != nil { + size += allocation(int64(unsafe.Sizeof(*e.Syntax.Errors))) + } + for _, row := range e.Delta.Symbols { + size += allocation(int64(len(row.ID))) + allocation(int64(len(row.Kind))) + allocation(int64(len(row.Signature))) + } + for _, value := range []string{e.Delta.State, e.Delta.Reason, e.Syntax.State, e.Syntax.Language} { + size += allocation(int64(len(value))) + } + return size +} + +func fileViewForEntry(entry *projectionEntry) mutationFileView { + view := mutationFileView{State: "projected", DeltaState: "unavailable", DeltaReason: "baseline_unknown", SyntaxHealth: projectionSyntaxHealth(entry)} + if entry != nil && entry.fileView != nil { + evidence := entry.fileView + view.DeltaState, view.DeltaReason = evidence.Delta.State, evidence.Delta.Reason + view.Counts, view.Symbols, view.Truncated = evidence.Delta.Counts, evidence.Delta.Symbols, evidence.Delta.Truncated + view.SyntaxHealth = evidence.Syntax + } + return view +} + +func mutationFileGraphHint(payload map[string]any, exact bool) string { + status, ok := payload["graph_status"].(string) + if !ok || status == "" { + status = "unobserved" + } + if exact { + return fmt.Sprintf("file current; recorded graph status: %s", status) + } + return fmt.Sprintf("file readback unavailable; recorded graph status: %s", status) +} + +func baselineForPreparedWrite(absPath string, before []byte) *plannedSource { + if before != nil { + return &plannedSource{path: absPath, before: before, existed: true} + } + _, err := os.Lstat(absPath) + if os.IsNotExist(err) { + return &plannedSource{path: absPath, existed: false} + } + return nil +} + +// Capture only a bounded, regular source under the existing physical path +// admission. Failure makes delta evidence unavailable; it never grants writes. +func captureFileViewBaseline(ctx context.Context, absPath string) *plannedSource { + if !mutationPathAdmitted(ctx, pathkey.MutationKey(absPath)) { + return nil + } + before, err := statDiskStamp(absPath) + if err != nil { + return nil + } + if before.info == nil { + return &plannedSource{path: absPath, existed: false} + } + if !before.info.Mode().IsRegular() || before.info.Size() > 1<<20 { + return nil + } + files, err := source.NewFilesystemSource(filepath.Dir(absPath)) + if err != nil { + return nil + } + defer files.Close() + raw, _, err := files.ReadRegularFile(ctx, filepath.Base(absPath), 1<<20) + if err != nil { + return nil + } + after, err := statDiskStamp(absPath) + if err != nil || !before.matches(after) || ctx.Err() != nil { + return nil + } + return &plannedSource{path: absPath, before: raw, existed: true} +} diff --git a/internal/mcp/source_projection_file_view_test.go b/internal/mcp/source_projection_file_view_test.go new file mode 100644 index 000000000..50e26c6a2 --- /dev/null +++ b/internal/mcp/source_projection_file_view_test.go @@ -0,0 +1,119 @@ +package mcp + +import ( + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "go.uber.org/zap" +) + +func TestMutationFileViewContainsCompleteDeltaAndCheckedSyntax(t *testing.T) { + s, root := ackModeServer(t, "local") + published := make(chan indexer.MutationResult, 1) + defer close(published) + s.SetWatcher(mutationTestWatcher{done: published}) + result := callTool(t, s, "edit_file", map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(1) }\nfunc FileViewAdded() {}", "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + payload := extractTextResult(t, result) + view, ok := payload["file_view"].(map[string]any) + require.True(t, ok, "ACK file_view must carry the structured delta, not an availability-only scalar") + require.Equal(t, "projected", view["state"]) + require.Equal(t, "available", view["delta_state"]) + counts, ok := view["counts"].(map[string]any) + require.True(t, ok) + require.EqualValues(t, 4, counts["before"], "fixture includes Config, Port, main and helper") + require.EqualValues(t, 5, counts["after"]) + require.EqualValues(t, 1, counts["added"]) + require.EqualValues(t, 1, counts["changed"]) + require.EqualValues(t, 0, counts["removed"]) + require.EqualValues(t, 2, counts["total_changes"]) + require.Len(t, view["symbols"], 2) + syntax, ok := view["syntax_health"].(map[string]any) + require.True(t, ok) + require.Equal(t, true, syntax["checked"]) + require.EqualValues(t, 0, syntax["errors"]) + require.Equal(t, "go", syntax["language"]) + require.Contains(t, payload["graph_status_hint"], "file current") + require.Contains(t, payload["graph_status_hint"], "pending") + require.Equal(t, "fresh", payload["tier_state"].(map[string]any)["file"]) +} + +func TestMutationReplayRefreshesActualTicketTiersWithoutAnotherParse(t *testing.T) { + s, root := ackModeServer(t, "local") + watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 60000}, zap.NewNop()) + require.NoError(t, err) + s.SetWatcher(watcher) + require.NoError(t, watcher.Start([]string{root})) + s.graph.ResolveMutex().Lock() + unlock := sync.OnceFunc(s.graph.ResolveMutex().Unlock) + stop := sync.OnceFunc(func() { require.NoError(t, watcher.Stop()) }) + t.Cleanup(func() { unlock(); stop() }) + args := map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(2) }", "mutation_id": "tier-replay-real-watcher", "format": "json"} + first := callTool(t, s, "edit_file", args) + require.False(t, first.IsError, "%v", first.Content) + payload := extractTextResult(t, first) + require.Equal(t, true, payload["reindex_pending"]) + tiers, ok := payload["tier_state"].(map[string]any) + require.True(t, ok) + require.Equal(t, "pending", tiers["relations"]) + receipt, ok := payload["reindex_receipt"].(string) + require.True(t, ok) + unlock() + waitCheckoutReceipt(t, s, receipt) + stop() // join native echoes before the replay parse-count observation + before := parser.ParseCount() + replay := callTool(t, s, "edit_file", args) + require.False(t, replay.IsError, "%v", replay.Content) + current := extractTextResult(t, replay) + require.Equal(t, "fresh", current["graph_status"]) + require.NotContains(t, current, "reindex_pending", "a pending ACK cache cannot override the actual terminal receipt") + liveTiers, ok := current["tier_state"].(map[string]any) + require.True(t, ok) + require.Equal(t, "fresh", liveTiers["relations"]) + require.Equal(t, before, parser.ParseCount(), "live receipt replay cannot parse or rewrite source") + require.Equal(t, "local", current["ack_mode"]) + require.NotEmpty(t, current["content_sha256"]) +} + +func requireMutationFileViewState(t *testing.T, payload map[string]any, expected string) { + t.Helper() + switch view := payload["file_view"].(type) { + case mutationFileView: + require.Equal(t, expected, view.State) + require.NotEmpty(t, view.DeltaState) + require.NotEmpty(t, view.SyntaxHealth.State) + case map[string]any: + require.Equal(t, expected, view["state"]) + require.NotEmpty(t, view["delta_state"]) + require.Contains(t, view, "syntax_health") + default: + t.Fatalf("file_view must be a structured object, got %T", view) + } +} + +func TestMutationFileViewCacheRetainsOnlyBoundedRegistryEvidence(t *testing.T) { + s, root := ackModeServer(t, "local") + published := make(chan indexer.MutationResult, 1) + defer close(published) + s.SetWatcher(mutationTestWatcher{done: published}) + result := callTool(t, s, "edit_file", map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(3) }", "mutation_id": "bounded-delta-replay", "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + payload := extractTextResult(t, result) + record, found := s.mutationCommits.byReceipt(payload["mutation_receipt"].(string)) + require.True(t, found) + cached, found := record.replayResponse() + require.True(t, found) + require.NotContains(t, cached, "file_view", "only the capped projection registry may retain symbol delta strings") + require.Equal(t, "fresh", payload["tier_state"].(map[string]any)["file"]) + pendingFileProjections.markSuspect(filepath.Join(root, "main.go")) + unavailable := make(map[string]any) + attachMutationCommit(unavailable, record) + requireMutationFileViewState(t, unavailable, "unavailable") + require.Equal(t, "pending", unavailable["tier_state"].(map[string]string)["file"]) + require.Nil(t, unavailable["file_view"].(mutationFileView).Counts) +} diff --git a/internal/mcp/source_projection_frontier.go b/internal/mcp/source_projection_frontier.go new file mode 100644 index 000000000..8f4280a54 --- /dev/null +++ b/internal/mcp/source_projection_frontier.go @@ -0,0 +1,75 @@ +package mcp + +import ( + "context" + "time" + "weak" +) + +// This caller-owned slice dies after the held source frontier finishes. The +// bounded physical ledger never strongly retains a graph or a prepared delta. +type committedFileProjection struct { + record *mutationCommitRecord + prepared *preparedFileProjection +} + +func (s *Server) publishCommittedProjectionCohort(ctx context.Context, writes []committedFileProjection) { + // A cancellation after a successful physical write cannot discard that + // write's file facts. This bounded verification does no source mutation. + checked, cancel := context.WithTimeout(context.WithoutCancel(ctx), 150*time.Millisecond) + defer cancel() + members := make([]projectionCohortMember, 0, len(writes)) + owners := make([]*mutationCommitRecord, 0, len(writes)) + for _, write := range writes { + record, prepared := write.record, write.prepared + reason := "projection_not_prepared" + if prepared != nil && prepared.refusal != "" { + reason = prepared.refusal + } + if record == nil { + continue + } + record.mu.RLock() + sequence, committed, sha := record.commitSeq, record.disk == mutationDiskCommitted, record.contentSHA256 + record.mu.RUnlock() + if !committed { + continue + } + if prepared != nil && prepared.entry != nil { + entry, err := s.verifyPreparedProjection(checked, prepared, sequence, sha) + if err == nil { + members = append(members, projectionCohortMember{Entry: entry, Expected: prepared.expected}) + owners = append(owners, record) + continue + } + reason = "readback_unavailable" + } + record.mu.Lock() + record.fileReason = reason + record.readbackExact = false + record.mu.Unlock() + } + err := pendingFileProjections.publishCohort(members) + for i, record := range owners { + entry := members[i].Entry + record.mu.Lock() + if err != nil { + record.fileReason = "projection_refused" + if refusal, ok := err.(projectionRefusal); ok { + record.fileReason = string(refusal) + } + record.readbackExact = false + } else { + record.fileProjection = weak.Make(entry) + record.readbackExact = true + record.fileReason = "" + if !entry.tombstone && !entry.parseChecked { + record.fileReason = "syntax_unknown" + } + if !entry.tombstone && entry.parseChecked && entry.parseErrors > 0 { + record.fileReason = "parse_errors" + } + } + record.mu.Unlock() + } +} diff --git a/internal/mcp/source_projection_gate.go b/internal/mcp/source_projection_gate.go new file mode 100644 index 000000000..9cf4245f8 --- /dev/null +++ b/internal/mcp/source_projection_gate.go @@ -0,0 +1,117 @@ +package mcp + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + + "github.com/zzet/gortex/internal/pathkey" + + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/indexer" +) + +// prepareAcknowledgedProjection builds only caller-owned file facts while the +// original source path is still held. A refused projection preserves the +// existing syntax gate; it cannot relax write admission. +func (s *Server) prepareAcknowledgedProjection(ctx context.Context, path, absPath string, before, after []byte) (*preparedFileProjection, parseGateResult) { + s.noteAcknowledgementParser(ctx, "source_projection_parse_gate") + lap := s.beginAcknowledgementLap(ctx, editAckParseGate) + defer lap.finish() + prepared, syntax := s.buildFileProjection(ctx, absPath, after) + gate := projectedParseGate(path, before, after, syntax) + lap.applicable = gate.Checked + lap.finish() + s.prepareFileViewEvidence(ctx, prepared, baselineForPreparedWrite(absPath, before), after) + return prepared, gate +} + +// Coordinate preservation does not prove equal syntax: a prepass can blank +// bytes without moving them. Reuse only a checked full parse of identical +// candidate bytes and the very same gate language, never a default zero. +func projectedParseGate(path string, before, after []byte, projection *indexer.Projection) parseGateResult { + if projection != nil && projection.ParseChecked && projection.ParseErrors >= 0 && projection.Language == parseGateLanguage(path) { + sum := blake3.Sum256(after) + if projection.ContentHash == hex.EncodeToString(sum[:]) { + return checkParseGateWithVerdict(path, before, after, true, projection.ParseErrors) + } + } + return checkParseGate(path, before, after) +} + +// This short-lived preparation never becomes a second authority or a ledger +// graph cache. The registry alone may retain its admitted entry after commit. +type preparedFileProjection struct { + key string + rawHash [32]byte + contentSHA256 string + entry *projectionEntry + expected *projectionEntry + refusal string +} + +func (s *Server) buildFileProjection(ctx context.Context, absPath string, raw []byte) (*preparedFileProjection, *indexer.Projection) { + key := pathkey.MutationKey(absPath) + sum := sha256.Sum256(raw) + prepared := &preparedFileProjection{key: key, rawHash: blake3.Sum256(raw), contentSHA256: hex.EncodeToString(sum[:]), expected: pendingFileProjections.snap.Load().entries[key]} + if !pendingFileProjections.enabled { + prepared.refusal = "disabled" + return prepared, nil + } + if ctx.Err() != nil { + prepared.refusal = "cancelled" + return prepared, nil + } + ownerPath := s.overlayOwnerAbsPath(ctx, absPath) + idx := s.pickIndexerForPath(ownerPath) + if idx == nil { + prepared.refusal = "parser_unavailable" + return prepared, nil + } + projection, err := idx.ProjectSource(ctx, ownerPath, raw) + if err != nil { + switch { + case errors.Is(err, indexer.ErrProjectionTooLarge): + prepared.refusal = "source_too_large" + case errors.Is(err, indexer.ErrProjectionLanguage): + prepared.refusal = "language_not_projected" + case errors.Is(err, indexer.ErrProjectionTransform): + prepared.refusal = "transform_not_projected" + case errors.Is(err, indexer.ErrProjectionNoSlot): + prepared.refusal = "parse_slot_deadline" + case errors.Is(err, indexer.ErrProjectionDeadline): + prepared.refusal = "build_deadline" + default: + prepared.refusal = "extraction_refused" + } + return prepared, nil + } + prepared.entry = newProjectionEntry(absPath, idx.RepoPrefix(), projection, raw, 0, diskStamp{}) + prepared.entry.contentSHA256 = prepared.contentSHA256 + prepared.entry.size += 128 // separately owned raw SHA256 and allocation/header allowance + return prepared, projection +} + +// Removal owns the directory entry, including a leaf symlink; it never parses +// or follows the removed leaf to construct the file tombstone. +func (s *Server) buildRemovalProjection(ctx context.Context, relPath, absPath string) *preparedFileProjection { + key := pathkey.MutationKey(absPath) + prepared := &preparedFileProjection{key: key, expected: pendingFileProjections.snap.Load().entries[key]} + if !pendingFileProjections.enabled { + prepared.refusal = "disabled" + return prepared + } + if ctx.Err() != nil { + prepared.refusal = "cancelled" + return prepared + } + idx := s.pickIndexerForPath(s.overlayOwnerAbsPath(ctx, absPath)) + if idx == nil { + prepared.refusal = "parser_unavailable" + return prepared + } + graphPath := s.resolveOverlayGraphPathForRequest(ctx, relPath, absPath) + prepared.entry = newProjectionTombstone(absPath, graphPath, idx.RepoPrefix(), 0) + return prepared +} diff --git a/internal/mcp/source_projection_gate_test.go b/internal/mcp/source_projection_gate_test.go new file mode 100644 index 000000000..bb7206d87 --- /dev/null +++ b/internal/mcp/source_projection_gate_test.go @@ -0,0 +1,88 @@ +package mcp + +import ( + "context" + "encoding/hex" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" +) + +func TestPreparedProjectionSyntaxGateParsesCleanCandidateOnce(t *testing.T) { + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + ctx, release, err := acquireMutationPath(context.Background(), path) + require.NoError(t, err) + defer release() + before := []byte("package p\nfunc Good() {}\n") + for _, test := range []struct { + name, source string + parses uint64 + blocked bool + }{ + {"clean", "package p\nfunc Better() {}\n", 1, false}, + {"regression", "package p\nfunc Better() { _ = }\n", 2, true}, + {"repair", "package p\nfunc Better() {}\n", 1, false}, + } { + t.Run(test.name, func(t *testing.T) { + old := before + if test.name == "repair" { + old = []byte("package p\nfunc Good() { _ = }\n") + } + count := parser.ParseCount() + entry, verdict := s.prepareAcknowledgedProjection(ctx, "main.go", path, old, []byte(test.source)) + parses := parser.ParseCount() - count + require.NotNil(t, entry) + require.NotNil(t, entry.entry, entry.refusal) + require.True(t, verdict.Checked) + require.Equal(t, test.blocked, verdict.Blocked) + require.Equal(t, test.parses, parses, "projection and gate must share the exact candidate parse; only broken candidates parse old bytes") + }) + } +} + +func TestProjectedParseGateUnknownOrDifferentBytesUseOriginalGate(t *testing.T) { + before := []byte("package p\nfunc Good() {}\n") + after := []byte("package p\nfunc Bad() { _ = }\n") + sum := blake3.Sum256(after) + hash := hex.EncodeToString(sum[:]) + for _, test := range []struct { + name string + projection *indexer.Projection + }{ + {"absent", nil}, + {"old or plugin response", &indexer.Projection{Language: "go", ContentHash: hash}}, + {"invalid negative", &indexer.Projection{Language: "go", ContentHash: hash, ParseChecked: true, ParseErrors: -1}}, + {"different parsed bytes", &indexer.Projection{Language: "go", ContentHash: "different parsed source", ParseChecked: true}}, + {"different grammar", &indexer.Projection{Language: "python", ContentHash: hash, ParseChecked: true}}, + } { + t.Run(test.name, func(t *testing.T) { + expected := checkParseGate("main.go", before, after) + count := parser.ParseCount() + actual := projectedParseGate("main.go", before, after, test.projection) + require.Equal(t, expected, actual) + require.True(t, actual.Blocked) + require.EqualValues(t, 2, parser.ParseCount()-count, "unknown proof must parse candidate and old bytes") + }) + } +} + +func TestProjectedParseGateKeepsBinaryAndOldErrorSemantics(t *testing.T) { + for _, test := range []struct{ before, after string }{ + {"package p\n", "package p\nfunc F() { _ = }\n"}, + {"package p\nfunc F() { _ = }\n", "package p\nfunc F() { _ = }\n"}, + {"package p\nfunc F() { _ = }\n", "package p\n"}, + {"package p\nfunc F() { _ = }\n", "package p\x00"}, + {"package p\x00", "package p\n"}, + } { + after := []byte(test.after) + count, checked := parseErrorCount("go", after) + sum := blake3.Sum256(after) + projection := &indexer.Projection{Language: "go", ContentHash: hex.EncodeToString(sum[:]), ParseChecked: checked, ParseErrors: count} + require.Equal(t, checkParseGate("main.go", []byte(test.before), after), projectedParseGate("main.go", []byte(test.before), after, projection)) + } +} diff --git a/internal/mcp/source_projection_hash_test.go b/internal/mcp/source_projection_hash_test.go new file mode 100644 index 000000000..295e6cb35 --- /dev/null +++ b/internal/mcp/source_projection_hash_test.go @@ -0,0 +1,55 @@ +package mcp + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" +) + +func TestMutationContentSHA256UsesActualCommittedBytesAndSurvivesReplay(t *testing.T) { + s, root := setupTestServer(t) + published := make(chan indexer.MutationResult, 1) + defer close(published) + s.SetWatcher(mutationTestWatcher{done: published}) + s.mutationReindexWait = time.Nanosecond + path := filepath.Join(root, "main.go") + args := map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() { println(\"Δ\") }", "mutation_id": "raw-sha-proof", "format": "json"} + before := parser.ParseCount() + result := callTool(t, s, "edit_file", args) + require.False(t, result.IsError, "%v", result.Content) + require.EqualValues(t, 1, parser.ParseCount()-before, "raw byte identity must reuse the one candidate parse") + raw, err := os.ReadFile(path) + require.NoError(t, err) + sum := sha256.Sum256(raw) + expected := hex.EncodeToString(sum[:]) + payload := extractTextResult(t, result) + require.Equal(t, expected, payload["content_sha256"]) + require.NotEqual(t, gitBlobSHA(raw), expected) + record, found := s.mutationCommits.byReceipt(payload["mutation_receipt"].(string)) + require.True(t, found) + require.NotNil(t, record) + require.Equal(t, expected, s.mutationStatusPayload(record)["content_sha256"]) + replayParses := parser.ParseCount() + replay := callTool(t, s, "edit_file", args) + require.False(t, replay.IsError, "%v", replay.Content) + require.Equal(t, expected, extractTextResult(t, replay)["content_sha256"]) + require.Equal(t, replayParses, parser.ParseCount(), "replay must not parse again to reconstruct a hash") +} + +func TestMutationContentSHA256DoesNotInventHashForModeledLegacyRecord(t *testing.T) { + s := newMutationServer() + path := filepath.Join(t.TempDir(), "legacy.go") + record := s.beginMutationCommit(t.Context(), "write_file", "", "", "legacy.go", path) + record.markCommitted("legacy-git-blob", 99) + payload := map[string]any{} + attachMutationCommit(payload, record) + require.NotContains(t, payload, "content_sha256", "a blob label is not a verified raw-byte SHA256") + require.NotContains(t, s.mutationStatusPayload(record), "content_sha256") +} diff --git a/internal/mcp/source_projection_removal_test.go b/internal/mcp/source_projection_removal_test.go new file mode 100644 index 000000000..3a127aabd --- /dev/null +++ b/internal/mcp/source_projection_removal_test.go @@ -0,0 +1,106 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +func TestGuardedRemovalPublishesExactTombstone(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + require.NotNil(t, s.graph.GetNode("main.go::helper")) + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + record, err := s.commitFileMutation(ctx, "overlay_merge", "", "", "main.go", path, nil, 0, sourceMutationOperation{kind: sourceRemove}) + require.NoError(t, err) + require.Equal(t, mutationDiskCommitted, record.diskStatus()) + _, err = os.Lstat(path) + require.ErrorIs(t, err, os.ErrNotExist) + entry, err := pendingFileProjections.lookup(path) + require.NoError(t, err) + require.NotNil(t, entry, "a guarded source removal must admit a file tombstone at the physical commit") + require.True(t, entry.tombstone) + require.Positive(t, entry.commitSeq) + require.Nil(t, graph.NewOverlaidView(s.graph, entry.layer).GetNode("main.go::helper")) + require.NotNil(t, s.graph.GetNode("main.go::helper"), "the tombstone does not mutate shared graph state") + response := make(map[string]any) + attachMutationCommit(response, record) + requireMutationFileViewState(t, response, "projected") + require.Equal(t, true, response["readback_exact"]) +} + +func TestGuardedRemovalTombstoneOwnsLeafEntryNotLinkTarget(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + target := filepath.Join(root, "main.go") + before, err := os.ReadFile(target) + require.NoError(t, err) + link := filepath.Join(root, "link.go") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + ctx, release, err := acquireMutationPath(t.Context(), link) + require.NoError(t, err) + defer release() + _, err = s.commitFileMutation(ctx, "overlay_merge", "", "", "link.go", link, nil, 0, sourceMutationOperation{kind: sourceRemove}) + require.NoError(t, err) + current, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, before, current) + entry := liveEntry(pendingFileProjections, link) + require.NotNil(t, entry) + require.True(t, entry.tombstone) + require.Equal(t, "link.go", entry.graphPath) + require.Nil(t, liveEntry(pendingFileProjections, target)) + require.NotNil(t, graph.NewOverlaidView(s.graph, entry.layer).GetNode("main.go::helper")) +} + +func TestGuardedRemovalRefusesLiveTombstoneAfterRecreation(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + record, err := s.commitFileMutation(ctx, "overlay_merge", "", "", "main.go", path, nil, 0, sourceMutationOperation{kind: sourceRemove}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, []byte("package main\n"), 0o600)) + response := make(map[string]any) + attachMutationCommit(response, record) + requireMutationFileViewState(t, response, "unavailable") + require.Equal(t, false, response["readback_exact"]) + entry, err := pendingFileProjections.lookup(path) + require.ErrorIs(t, err, errProjectionExternalWrite) + require.Nil(t, entry) +} + +func TestGuardedRemovalHonorsProjectionDisable(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + pendingFileProjections.enabled = false + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + record, err := s.commitFileMutation(ctx, "overlay_merge", "", "", "main.go", path, nil, 0, sourceMutationOperation{kind: sourceRemove}) + require.NoError(t, err) + response := make(map[string]any) + attachMutationCommit(response, record) + require.Equal(t, "disabled", response["file_reason"]) + requireMutationFileViewState(t, response, "unavailable") + require.Empty(t, pendingFileProjections.snap.Load().entries) +} diff --git a/internal/mcp/source_projection_replay_test.go b/internal/mcp/source_projection_replay_test.go new file mode 100644 index 000000000..cd614e41c --- /dev/null +++ b/internal/mcp/source_projection_replay_test.go @@ -0,0 +1,63 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" +) + +func TestReplayAfterNewerCommitKeepsLiveProjection(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + done := make(chan indexer.MutationResult, 2) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + s.mutationReindexWait = time.Nanosecond + path := filepath.Join(root, "main.go") + firstArgs := map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc FirstProjection() {}", "mutation_id": "projection-old", "format": "json"} + first := callTool(t, s, "edit_file", firstArgs) + require.False(t, first.IsError) + originalAck := extractTextResult(t, first) + requireMutationFileViewState(t, originalAck, "projected") + firstEntry := liveEntry(pendingFileProjections, path) + require.NotNil(t, firstEntry) + initialParses := parser.ParseCount() + same := callTool(t, s, "edit_file", firstArgs) + require.False(t, same.IsError) + requireMutationFileViewState(t, extractTextResult(t, same), "projected") + require.Equal(t, true, extractTextResult(t, same)["readback_exact"]) + require.Equal(t, initialParses, parser.ParseCount()) + require.Same(t, firstEntry, liveEntry(pendingFileProjections, path)) + second := callTool(t, s, "edit_file", map[string]any{"path": path, "old_string": "func FirstProjection() {}", "new_string": "func SecondProjection() {}", "mutation_id": "projection-new", "format": "json"}) + require.False(t, second.IsError) + live := liveEntry(pendingFileProjections, path) + require.NotSame(t, firstEntry, live) + beforeReplay, err := os.ReadFile(path) + require.NoError(t, err) + parses := parser.ParseCount() + replay := callTool(t, s, "edit_file", firstArgs) + require.False(t, replay.IsError) + payload := extractTextResult(t, replay) + require.Equal(t, true, payload["replayed"]) + require.Equal(t, originalAck["mutation_receipt"], payload["mutation_receipt"], "the physical receipt remains historical") + require.Equal(t, originalAck["commit_seq"], payload["commit_seq"]) + requireMutationFileViewState(t, payload, "unavailable") + require.Equal(t, false, payload["readback_exact"]) + require.Equal(t, "projection_not_live", payload["file_reason"]) + require.Equal(t, parses, parser.ParseCount(), "replay must neither parse nor resurrect the historical entry") + require.Same(t, live, liveEntry(pendingFileProjections, path)) + afterReplay, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, beforeReplay, afterReplay) + reader := graph.NewOverlaidView(s.graph, live.layer) + require.NotNil(t, reader.GetNode("main.go::SecondProjection")) + require.Nil(t, reader.GetNode("main.go::FirstProjection")) +} diff --git a/internal/mcp/source_projection_retirement.go b/internal/mcp/source_projection_retirement.go new file mode 100644 index 000000000..02d299f36 --- /dev/null +++ b/internal/mcp/source_projection_retirement.go @@ -0,0 +1,33 @@ +package mcp + +import "github.com/zzet/gortex/internal/pathkey" + +// This work belongs only to the daemon's canonical backend, whose lifetime is +// independent of a request/view pin. Checkout tickets never use this fallback: +// their retirement needs checked metadata from the selected generation stack. +type sourceProjectionRetirement struct { + registry *pendingProjections + backend projectionStore + key string +} + +func (s *Server) canonicalProjectionRetirement(absPath string) *sourceProjectionRetirement { + registry := pendingFileProjections + if !registry.enabled || len(registry.snap.Load().entries) == 0 { + return nil + } + backend, ok := s.graph.(projectionStore) + if !ok { + return nil + } + return &sourceProjectionRetirement{registry: registry, backend: backend, key: pathkey.MutationKey(absPath)} +} + +func (work *sourceProjectionRetirement) run() { + if work == nil { + return + } + // Retain the physical key captured at admission, rather than resolving an + // alias again after publication. The same hash/node/hash and COW checks run. + work.registry.retireSelected(work.backend, map[string]struct{}{work.key: {}}) +} diff --git a/internal/mcp/source_projection_retirement_callback_test.go b/internal/mcp/source_projection_retirement_callback_test.go new file mode 100644 index 000000000..60b3f64d1 --- /dev/null +++ b/internal/mcp/source_projection_retirement_callback_test.go @@ -0,0 +1,113 @@ +package mcp + +import ( + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" +) + +type projectionRetirementSignalStore struct { + *graph.Graph + once sync.Once + metadataRead chan struct{} +} + +func (s *projectionRetirementSignalStore) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { + rows, err := s.Graph.FileMetasByPaths(prefix, paths) + s.once.Do(func() { close(s.metadataRead) }) + return rows, err +} + +func TestCanonicalPublicationRetirementRequiresActualBackendContent(t *testing.T) { + for _, published := range []bool{true, false} { + name := "receipt_only" + if published { + name = "actual_publication" + } + t.Run(name, func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + registry := pendingFileProjections + t.Cleanup(func() { pendingFileProjections = previous }) + fixture := newProjectionFixture(t) + entry := fixture.edit("a.go", "package p\n\nfunc Published() {}\n") + require.NoError(t, registry.publish(entry)) + backend := &projectionRetirementSignalStore{Graph: fixture.g, metadataRead: make(chan struct{})} + s := newMutationServer() + s.graph = backend + s.indexer = fixture.idx + done := make(chan indexer.MutationResult, 1) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + ctx, release, err := acquireMutationPath(t.Context(), entry.absPath) + require.NoError(t, err) + admission := s.admitSourceMutations(ctx, entry.absPath) + require.NotNil(t, admission.receipt) + release() + if published { + require.NoError(t, fixture.idx.IndexFile(filepath.Join(fixture.dir, "a.go"))) + require.NotNil(t, fixture.g.GetNode("a.go::Published"), "prove actual graph publication independently of the completion ticket") + } else { + require.NotNil(t, fixture.g.GetNode("a.go::Old"), "the success notification alone cannot become content evidence") + } + done <- indexer.MutationResult{RequestedGeneration: admission.receipt.generation, AppliedGeneration: admission.receipt.generation, Reindexed: true} + select { + case <-admission.receipt.done: + case <-time.After(3 * time.Second): + t.Fatal("owned ticket did not complete") + } + select { + case <-backend.metadataRead: + case <-time.After(3 * time.Second): + t.Fatal("canonical publication never verified the pending projection against its backend") + } + if published { + require.Eventually(t, func() bool { return liveEntry(registry, entry.absPath) == nil }, 3*time.Second, time.Millisecond) + } else { + require.Same(t, entry, liveEntry(registry, entry.absPath)) + require.Zero(t, registry.retired.Load()) + } + }) + } +} + +type projectionRetirementCoreOnlyStore struct{ graph.Store } + +func TestCanonicalRetirementKeepsEntryWithoutMetadataCapability(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + fixture := newProjectionFixture(t) + entry := fixture.edit("a.go", "package p\n\nfunc Published() {}\n") + require.NoError(t, pendingFileProjections.publish(entry)) + s := newMutationServer() + s.graph = &projectionRetirementCoreOnlyStore{Store: fixture.g} + require.NotImplements(t, (*graph.FileMetaPathReader)(nil), s.graph) + work := s.canonicalProjectionRetirement(entry.absPath) + require.Nil(t, work, "a core wrapper must never be unwrapped to another metadata authority") + work.run() + require.Same(t, entry, liveEntry(pendingFileProjections, entry.absPath)) +} + +func TestInlinePublicationRetiresVerifiedProjection(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + registry := pendingFileProjections + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + result := callTool(t, s, "edit_file", map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc InlinePublished() {}", "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + response := extractTextResult(t, result) + require.Equal(t, true, response["reindexed"], "prove real embedded publication before checking cache retirement") + require.NotNil(t, s.graph.GetNode("main.go::InlinePublished")) + require.Positive(t, registry.published.Load(), "the source commit must have admitted its projection first") + require.Nil(t, liveEntry(registry, path), "synchronous canonical publication must retire content-verified file data too") + require.Positive(t, registry.retired.Load()) +} diff --git a/internal/mcp/source_projection_span_test.go b/internal/mcp/source_projection_span_test.go new file mode 100644 index 000000000..c8d2abfb3 --- /dev/null +++ b/internal/mcp/source_projection_span_test.go @@ -0,0 +1,84 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/parser" +) + +func TestSymbolSpanUsesMatchingPendingProjectionWithoutAnotherParse(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + raw := append([]byte("\n\n"), before...) + require.NoError(t, os.WriteFile(path, raw, 0o644)) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + projection, err := s.indexer.ProjectSource(t.Context(), path, raw) + require.NoError(t, err) + entry := newProjectionEntry(path, s.indexer.RepoPrefix(), projection, raw, 1, stamp) + require.NoError(t, pendingFileProjections.publish(entry)) + node := s.graph.GetNode("main.go::helper") + require.NotNil(t, node) + current := entry.layer.NodeByID(node.ID) + require.NotNil(t, current) + require.NotEqual(t, node.StartLine, current.StartLine) + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + parses := parser.ParseCount() + start, end, err := s.symbolLinesUnderLock(ctx, node, path, raw) + require.NoError(t, err) + require.Equal(t, current.StartLine, start) + require.Equal(t, current.EndLine, end) + require.EqualValues(t, 0, parser.ParseCount()-parses, "the same BLAKE3-matched file projection must serve the held-path declaration resolver") +} + +func TestSymbolSpanRejectsMismatchedProjectionBytesAndRemovedDeclaration(t *testing.T) { + for _, removed := range []bool{false, true} { + t.Run(map[bool]string{false: "shifted again", true: "removed"}[removed], func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + projectedBytes := append([]byte("\n\n"), before...) + require.NoError(t, os.WriteFile(path, projectedBytes, 0o644)) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + projection, err := s.indexer.ProjectSource(t.Context(), path, projectedBytes) + require.NoError(t, err) + entry := newProjectionEntry(path, s.indexer.RepoPrefix(), projection, projectedBytes, 1, stamp) + require.NoError(t, pendingFileProjections.publish(entry)) + node := s.graph.GetNode("main.go::helper") + require.NotNil(t, node) + current := append([]byte("\n\n\n\n"), before...) + if removed { + current = []byte(strings.ReplaceAll(string(current), "func helper() {}", "func Gone() {}")) + } + require.NoError(t, os.WriteFile(path, current, 0o644)) + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + parses := parser.ParseCount() + start, _, err := s.symbolLinesUnderLock(ctx, node, path, current) + if removed { + require.ErrorIs(t, err, errSymbolRemoved) + } else { + require.NoError(t, err) + require.Equal(t, node.StartLine+4, start) + } + require.EqualValues(t, 1, parser.ParseCount()-parses, "different raw identity must take the conservative full extraction") + }) + } +} diff --git a/internal/mcp/source_projection_tiers_test.go b/internal/mcp/source_projection_tiers_test.go new file mode 100644 index 000000000..ac31cc2da --- /dev/null +++ b/internal/mcp/source_projection_tiers_test.go @@ -0,0 +1,91 @@ +package mcp + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" +) + +type controlledTierEvidenceWatcher struct { + *indexer.Watcher + invalidate bool + observed chan bool +} + +func (w *controlledTierEvidenceWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + entry := pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)] + current := entry != nil && liveProjectionForAcknowledgement(entry) + if w.invalidate { + pendingFileProjections.markSuspect(path) + } + w.observed <- current + return w.Watcher.EnqueueToolMutation(ctx, path) +} + +func TestMutationAckIncludesExistingTierProducerEvidence(t *testing.T) { + for _, invalidate := range []bool{false, true} { + t.Run(map[bool]string{false: "live_projection", true: "observed_invalidation"}[invalidate], func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 60000}, zap.NewNop()) + require.NoError(t, err) + controlled := &controlledTierEvidenceWatcher{Watcher: watcher, invalidate: invalidate, observed: make(chan bool, 1)} + s.SetWatcher(controlled) + // Keep native delivery independent of this manual tool-admission test. + require.NoError(t, watcher.Start([]string{t.TempDir()})) + s.graph.ResolveMutex().Lock() + var receipt string + t.Cleanup(func() { + s.graph.ResolveMutex().Unlock() + if receipt != "" { + waitCheckoutReceipt(t, s, receipt) + } + require.NoError(t, watcher.Stop()) + }) + s.mutationReindexWait = time.Nanosecond + result := callTool(t, s, "edit_file", map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { _ = 3 }", "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + response := extractTextResult(t, result) + receipt, _ = response["reindex_receipt"].(string) + require.True(t, <-controlled.observed, "the real committed projection must be live before controlled invalidation") + require.NotZero(t, response["reindex_generation"], "prove real watcher admission before checking evidence") + states, ok := response["tier_state"].(map[string]any) + require.True(t, ok, "the acknowledgement must consume the existing ticket-owned tier producer") + require.Len(t, states, 4) + if invalidate { + require.Equal(t, "pending", states["file"], "observed invalidation cannot receive exact-file credit") + require.Equal(t, false, response["readback_exact"]) + require.Equal(t, "projection_not_live", response["file_reason"]) + } else { + require.Equal(t, "fresh", states["file"], "the live physical projection must supply the additional file tier") + require.Equal(t, true, response["readback_exact"]) + require.Equal(t, "projected", response["file_view"].(map[string]any)["state"]) + } + ack, ok := response["ack_at"].(string) + require.True(t, ok) + at, err := time.Parse(time.RFC3339Nano, ack) + require.NoError(t, err) + require.False(t, at.IsZero()) + for tier, field := range map[string]string{"file_graph": "file_graph_at", "relations": "relations_at", "derived": "derived_at"} { + require.Contains(t, []string{"pending", "fresh", "partial", "failed"}, states[tier]) + if states[tier] == "fresh" { + value, ok := response[field].(string) + require.True(t, ok, "fresh %s must have actual publication time", tier) + stamp, err := time.Parse(time.RFC3339Nano, value) + require.NoError(t, err) + require.False(t, stamp.IsZero()) + } + } + }) + } +} diff --git a/internal/mcp/source_projection_verify.go b/internal/mcp/source_projection_verify.go new file mode 100644 index 000000000..4b792b228 --- /dev/null +++ b/internal/mcp/source_projection_verify.go @@ -0,0 +1,63 @@ +package mcp + +import ( + "context" + "fmt" + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/indexer/source" + "github.com/zzet/gortex/internal/pathkey" + "path/filepath" +) + +// verifyPreparedProjection authenticates an after-image while its original +// physical path remains held. It performs bounded I/O before cache admission; +// the cohort registry itself never performs I/O or supplies disk authority. +// expectedSHA256 is the durable journal's after-image SHA, when applicable. +func (s *Server) verifyPreparedProjection(ctx context.Context, prepared *preparedFileProjection, sequence uint64, expectedSHA256 string) (*projectionEntry, error) { + if prepared == nil || prepared.entry == nil { + return nil, fmt.Errorf("projection preparation unavailable") + } + entry := prepared.entry + if sequence == 0 || !mutationPathAdmitted(ctx, prepared.key) || pathkey.MutationKey(entry.absPath) != prepared.key { + return nil, fmt.Errorf("projection held-path identity unavailable") + } + if err := ctx.Err(); err != nil { + return nil, err + } + before, err := statDiskStamp(entry.absPath) + if err != nil { + return nil, err + } + if entry.tombstone { + if before.info != nil || expectedSHA256 != "" { + return nil, fmt.Errorf("projection removal after-image changed") + } + return entry.committedCopy(sequence, before), nil + } + if expectedSHA256 != "" && prepared.contentSHA256 != expectedSHA256 { + return nil, fmt.Errorf("projection journal after-image mismatch") + } + if before.info == nil || !before.info.Mode().IsRegular() || before.info.Size() > 1<<20 { + return nil, fmt.Errorf("projection after-image is not a bounded regular file") + } + files, err := source.NewFilesystemSource(filepath.Dir(entry.absPath)) + if err != nil { + return nil, err + } + raw, _, readErr := files.ReadRegularFile(ctx, filepath.Base(entry.absPath), 1<<20) + files.Close() + after, err := statDiskStamp(entry.absPath) + if readErr != nil { + return nil, readErr + } + if err != nil { + return nil, err + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + if !before.matches(after) || blake3.Sum256(raw) != prepared.rawHash || pathkey.MutationKey(entry.absPath) != prepared.key || !mutationPathAdmitted(ctx, prepared.key) { + return nil, fmt.Errorf("projection after-image changed") + } + return entry.committedCopy(sequence, after), nil +} diff --git a/internal/mcp/source_projection_verify_test.go b/internal/mcp/source_projection_verify_test.go new file mode 100644 index 000000000..dc6b92372 --- /dev/null +++ b/internal/mcp/source_projection_verify_test.go @@ -0,0 +1,45 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPreparedProjectionVerificationRequiresHeldExactJournalAfterImage(t *testing.T) { + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + raw, err := os.ReadFile(path) + require.NoError(t, err) + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + prepared, _ := s.buildFileProjection(ctx, path, raw) + require.NotNil(t, prepared.entry) + before := pendingFileProjections.snap.Load() + _, err = s.verifyPreparedProjection(t.Context(), prepared, 1, prepared.contentSHA256) + require.Error(t, err, "an otherwise exact after-image is not held-path proof") + _, err = s.verifyPreparedProjection(ctx, prepared, 1, "different-journal-sha") + require.Error(t, err) + entry, err := s.verifyPreparedProjection(ctx, prepared, 1, prepared.contentSHA256) + require.NoError(t, err) + require.EqualValues(t, 1, entry.commitSeq) + require.Equal(t, prepared.contentSHA256, entry.contentSHA256) + require.NotSame(t, prepared.entry, entry) + require.Same(t, before, pendingFileProjections.snap.Load(), "verification alone must never publish a member") + canceled, cancel := context.WithCancel(ctx) + cancel() + _, err = s.verifyPreparedProjection(canceled, prepared, 1, prepared.contentSHA256) + require.ErrorIs(t, err, context.Canceled) + changed := append([]byte(nil), raw...) + changed[len(changed)-1] = ' ' + require.NoError(t, os.WriteFile(path, changed, 0o644)) + _, err = s.verifyPreparedProjection(ctx, prepared, 1, prepared.contentSHA256) + require.Error(t, err, "journal SHA cannot substitute for exact current after-image verification") + release() + _, err = s.verifyPreparedProjection(ctx, prepared, 1, prepared.contentSHA256) + require.Error(t, err, "a released path proof cannot authorize publication") +} diff --git a/internal/mcp/source_remaining_frontends_test.go b/internal/mcp/source_remaining_frontends_test.go new file mode 100644 index 000000000..a5f2413a4 --- /dev/null +++ b/internal/mcp/source_remaining_frontends_test.go @@ -0,0 +1,360 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" +) + +func TestScaffoldAdmitsOneFinalFileBeforeUnlockedPublicationWait(t *testing.T) { + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + publications := make(chan indexer.MutationResult, 1) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPath(ctx, path) + if err == nil { + release() + } + waiting <- err + return true + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "main.go::helper", "new_name": "ClonedHelper", "dry_run": false, "format": "json"} + ctx, note := withMutationCommitNote(t.Context()) + ctx, cancel := context.WithCancel(ctx) + finished := make(chan struct{}) + complete := sync.OnceFunc(func() { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + close(publications) + }) + var result *mcplib.CallToolResult + var resultErr error + parses := parser.ParseCount() + go func() { defer close(finished); result, resultErr = s.handleScaffold(ctx, request) }() + defer func() { + cancel() + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned scaffold did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.Equal(t, []string{path}, admitted) + case <-finished: + t.Fatalf("scaffold returned without admitting its physical source: %v %v", resultErr, result.Content) + case <-time.After(3 * time.Second): + t.Fatal("scaffold never admitted its physical source") + } + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(raw), "func ClonedHelper() {}") + require.EqualValues(t, 1, watcher.points.Load(), "multiple scaffold edits to one file have one final publication") + require.Len(t, note.verdict().Snapshots, 1, "same-file insertions are one physical candidate/write") + select { + case err := <-waiting: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("scaffold did not return its path lock before waiting") + } + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("published scaffold did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + require.EqualValues(t, 1, parser.ParseCount()-parses, "one full clean candidate parse per physical file; queued publisher is a no-parse fixture") +} + +func TestScaffoldRefusesExampleRemovedWhileWaitingForSourceLock(t *testing.T) { + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + writerCtx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + var once sync.Once + unlock := func() { once.Do(release) } + defer unlock() + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "main.go::helper", "new_name": "ClonedHelper", "dry_run": false, "format": "json"} + finished := make(chan struct{}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var result *mcplib.CallToolResult + var resultErr error + go func() { defer close(finished); result, resultErr = s.handleScaffold(ctx, request) }() + defer func() { + cancel() + unlock() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned queued scaffold did not join") + } + }() + waitForMutationRefs(t, path, 2) + before, err := os.ReadFile(path) + require.NoError(t, err) + current := []byte(strings.ReplaceAll(string(before), "func helper() {}", "func RemovedAlternative() {}")) + _, err = s.commitFileMutation(writerCtx, "write_file", "", "", "main.go", path, current, 0o644) + require.NoError(t, err) + unlock() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("queued scaffold did not return") + } + require.NoError(t, resultErr) + require.True(t, result.IsError, "a stale outside-lock template cannot resurrect a removed example") + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, current, after) +} + +func TestScaffoldUsesTheRequestedReaderInsteadOfCanonicalGraph(t *testing.T) { + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + raw, err := os.ReadFile(path) + require.NoError(t, err) + raw = append(raw, []byte("\nfunc SelectedOnly() int { return 7 }\n")...) + require.NoError(t, os.WriteFile(path, raw, 0o644)) + projection, err := s.indexer.ProjectSource(t.Context(), path, raw) + require.NoError(t, err) + selected := graph.New() + selected.AddBatch(projection.Nodes, projection.Edges) + require.NotNil(t, selected.GetNode("main.go::SelectedOnly")) + require.Nil(t, s.graph.GetNode("main.go::SelectedOnly")) + ctx := withRequestView(t.Context(), &requestView{reader: selected, viewRoot: root}) + s.SetWatcher(mutationTestWatcher{}) + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "main.go::SelectedOnly", "new_name": "ClonedSelected", "dry_run": false, "format": "json"} + result, err := s.handleScaffold(ctx, request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(after), "func ClonedSelected() int { return 7 }") + require.Nil(t, s.graph.GetNode("main.go::ClonedSelected"), "the selected reader cannot become a canonical graph write") +} + +func TestOverlayDiskMergeAdmitsWholeFrontierBeforeUnlockedWait(t *testing.T) { + s, session, _, first, second, ctx := branchBootstrap(t) + _, _ = invokeTool(t, s, ctx, "overlay_fork", map[string]any{"name": "queued-writer", "activate": true}) + cohort := map[string]string{first: "package main\nfunc Target() {}\nfunc AddedTarget() {}\n", second: "package main\nfunc Caller() { Target() }\nfunc AddedCaller() {}\n"} + for path, body := range cohort { + require.NoError(t, s.OverlayManager().Push(session, daemon.OverlayFile{Path: path, Content: body}, nil)) + } + publications := make(chan indexer.MutationResult, 2) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + paths := []string{first, second} + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + check, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPaths(check, paths) + if err == nil { + release() + } + waiting <- err + return true + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"from": "queued-writer", "to_disk": true, "format": "json"} + ctx, cancel := context.WithCancel(ctx) + finished := make(chan struct{}) + complete := sync.OnceFunc(func() { + for range 2 { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + } + close(publications) + }) + var result *mcplib.CallToolResult + var resultErr error + go func() { defer close(finished); result, resultErr = s.handleOverlayMerge(ctx, request) }() + defer func() { + cancel() + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned overlay merge did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.ElementsMatch(t, paths, admitted) + case <-finished: + t.Fatalf("overlay merge returned without watcher frontier admission: %v %v", resultErr, result.Content) + case <-time.After(3 * time.Second): + t.Fatal("overlay merge never admitted its source frontier") + } + for path, body := range cohort { + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, body, string(raw)) + } + select { + case err := <-waiting: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("overlay merge kept source locks during passive wait") + } + managerRead := make(chan error, 1) + go func() { _, err := s.OverlayManager().Branches(session); managerRead <- err }() + select { + case err := <-managerRead: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("overlay manager mutex was retained across publication wait") + } + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("published overlay merge did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) +} + +func TestOverlayDiskMergeRefusesChangedBranchAfterPathWait(t *testing.T) { + for _, change := range []string{"content", "membership"} { + t.Run(change, func(t *testing.T) { + s, session, _, first, second, ctx := branchBootstrap(t) + _, _ = invokeTool(t, s, ctx, "overlay_fork", map[string]any{"name": "changing", "activate": true}) + require.NoError(t, s.OverlayManager().Push(session, daemon.OverlayFile{Path: first, Content: "package main\nfunc Replacement() {}\n"}, nil)) + before, err := os.ReadFile(first) + require.NoError(t, err) + _, release, err := acquireMutationPath(t.Context(), first) + require.NoError(t, err) + unlock := sync.OnceFunc(release) + defer unlock() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"from": "changing", "to_disk": true} + type response struct { + result *mcplib.CallToolResult + err error + } + done := make(chan response, 1) + go func() { result, err := s.handleOverlayMerge(ctx, request); done <- response{result, err} }() + defer func() { + cancel() + unlock() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("owned branch merge did not join") + } + }() + waitForMutationRefs(t, first, 2) + path := first + if change == "membership" { + path = second + } + changed := make(chan error, 1) + go func() { + changed <- s.OverlayManager().Push(session, daemon.OverlayFile{Path: path, Content: "package main\nfunc NewerOverlay() {}\n"}, nil) + }() + select { + case err := <-changed: + require.NoError(t, err) + case <-time.After(time.Second): + unlock() + require.NoError(t, <-changed) + t.Fatal("branch mutex held while acquiring disk paths") + } + unlock() + select { + case result := <-done: + done <- result // leave the owned join receipt for cleanup + require.NoError(t, result.err) + require.True(t, result.result.IsError, "%v", result.result.Content) + case <-time.After(3 * time.Second): + t.Fatal("changed branch merge did not refuse") + } + after, err := os.ReadFile(first) + require.NoError(t, err) + require.Equal(t, before, after, "cohort mismatch must refuse before the first physical write") + files, err := s.OverlayManager().Files(session) + require.NoError(t, err) + require.Contains(t, files, path, "refusal must retain the newer source branch") + }) + } +} + +func TestOverlayDiskMergeLaterCancellationStillAdmitsCommittedSubset(t *testing.T) { + s, session, _, first, second, ctx := branchBootstrap(t) + _, _ = invokeTool(t, s, ctx, "overlay_fork", map[string]any{"name": "partial", "activate": true}) + paths := []string{first, second} + slices.Sort(paths) + originalSecond, err := os.ReadFile(paths[1]) + require.NoError(t, err) + body := "package main\nfunc PartialReplacement() {}\n" + for _, path := range paths { + require.NoError(t, s.OverlayManager().Push(session, daemon.OverlayFile{Path: path, Content: body}, nil)) + } + publications := make(chan indexer.MutationResult) + defer close(publications) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + ctx, note := withMutationCommitNote(ctx) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + s.mutationPreCommitHook = func(record *mutationCommitRecord) { + if record.absPath == paths[1] { + cancel() + } + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"from": "partial", "to_disk": true} + result, err := s.handleOverlayMerge(ctx, request) + require.NoError(t, err) + require.True(t, result.IsError, "%v", result.Content) + actualFirst, err := os.ReadFile(paths[0]) + require.NoError(t, err) + require.Equal(t, body, string(actualFirst)) + actualSecond, err := os.ReadFile(paths[1]) + require.NoError(t, err) + require.Equal(t, originalSecond, actualSecond) + select { + case admitted := <-watcher.announced: + require.Equal(t, []string{paths[0]}, admitted) + default: + t.Fatal("later cancellation lost the first physical file's publication admission") + } + var committed int + for _, snapshot := range note.verdict().Snapshots { + if snapshot.DiskStatus == mutationDiskCommitted { + committed++ + require.True(t, snapshot.GraphRecorded, "the committed subset retains its publication outcome") + } + } + require.Equal(t, 1, committed) +} diff --git a/internal/mcp/source_rename_frontier_test.go b/internal/mcp/source_rename_frontier_test.go new file mode 100644 index 000000000..d173d5b2b --- /dev/null +++ b/internal/mcp/source_rename_frontier_test.go @@ -0,0 +1,144 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" +) + +type renameFrontierWatcher struct { + mutationTestWatcher + points, sets atomic.Int64 + announced chan []string +} + +func (w *renameFrontierWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + w.points.Add(1) + select { + case w.announced <- []string{path}: + default: + } + return w.mutationTestWatcher.EnqueueToolMutation(ctx, path) +} +func (w *renameFrontierWatcher) EnqueueFileMutations(ctx context.Context, paths []string) (map[string]*indexer.MutationTicket, error) { + w.sets.Add(1) + tickets := make(map[string]*indexer.MutationTicket, len(paths)) + for _, path := range paths { + ticket, err := w.mutationTestWatcher.EnqueueToolMutation(ctx, path) + if err != nil { + return nil, err + } + tickets[path] = ticket + } + select { + case w.announced <- append([]string(nil), paths...): + default: + } + return tickets, nil +} + +func TestRenameAdmitsWholeFrontierAndReleasesPathsBeforeGraphWait(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + publications := make(chan indexer.MutationResult, 2) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + paths := []string{filepath.Join(root, "caller.go"), filepath.Join(root, "target.go")} + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + check, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPaths(check, paths) + if err == nil { + release() + } + waiting <- err + return true + } + ctx, cancel := context.WithCancel(t.Context()) + req := mcplib.CallToolRequest{} + req.Params.Name = "rename_symbol" + req.Params.Arguments = map[string]any{"id": "target.go::Target", "new_name": "Renamed", "format": "json"} + finished := make(chan struct{}) + var result *mcplib.CallToolResult + var resultErr error + complete := sync.OnceFunc(func() { + for range 2 { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + } + close(publications) + }) + go func() { defer close(finished); result, resultErr = s.handleRenameSymbol(ctx, req) }() + defer func() { + cancel() + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned rename did not join during cleanup") + } + }() + select { + case admitted := <-watcher.announced: + assert.ElementsMatch(t, paths, admitted, "admit the full physical frontier once") + case <-time.After(3 * time.Second): + t.Fatal("rename never admitted publication") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(raw), "Renamed", "every file must be committed before admission/wait") + } + assert.EqualValues(t, 1, watcher.sets.Load()) + assert.Zero(t, watcher.points.Load(), "whole-set support must not pay point admission per file") + select { + case err := <-waiting: + require.NoError(t, err, "a real competing path admission must succeed while publication is pending") + case <-time.After(time.Second): + t.Fatal("rename waits before finishing and releasing its source frontier") + } + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("published rename did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + require.Equal(t, "applied", decodeRenameResp(t, result)["status"]) +} + +func TestRenameCandidateProjectionAndGateParseEachFileOnce(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, _ := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + publications := make(chan indexer.MutationResult, 2) + for range 2 { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + } + close(publications) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + req := mcplib.CallToolRequest{} + req.Params.Name = "rename_symbol" + req.Params.Arguments = map[string]any{"id": "target.go::Target", "new_name": "Renamed", "format": "json"} + before := parser.ParseCount() + result, err := s.handleRenameSymbol(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + require.EqualValues(t, 2, parser.ParseCount()-before, "both clean candidates must share their full parse between syntax gate and pending projection") +} diff --git a/internal/mcp/source_rename_parallel_projection_test.go b/internal/mcp/source_rename_parallel_projection_test.go new file mode 100644 index 000000000..fb1f11f45 --- /dev/null +++ b/internal/mcp/source_rename_parallel_projection_test.go @@ -0,0 +1,113 @@ +package mcp + +import ( + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +type renameParallelExtractor struct { + base parser.Extractor + enabled atomic.Bool + active, max atomic.Int32 + entered chan struct{} + release chan struct{} +} + +func (e *renameParallelExtractor) Language() string { return "go" } +func (e *renameParallelExtractor) Extensions() []string { return []string{".go"} } +func (e *renameParallelExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult, error) { + if e.enabled.Load() { + active := e.active.Add(1) + defer e.active.Add(-1) + for prior := e.max.Load(); active > prior; prior = e.max.Load() { + if e.max.CompareAndSwap(prior, active) { + break + } + } + e.entered <- struct{}{} + <-e.release + } + return e.base.Extract(path, raw) +} + +func TestRenameBuildsBoundedParallelProjectionsInsideOneAcknowledgementLap(t *testing.T) { + t.Setenv("GORTEX_EDIT_PARSE_GATE", "1") + extractor := &renameParallelExtractor{base: languages.NewGoExtractor(), entered: make(chan struct{}, 3), release: make(chan struct{})} + registry := parser.NewRegistry() + registry.Register(extractor) + s, root := setupTestServerWithRegistry(t, registry) + var edits []renameEdit + var paths []string + for _, name := range []string{"first.go", "second.go", "third.go"} { + path := filepath.Join(root, name) + require.NoError(t, os.WriteFile(path, []byte("package main\nfunc Old() {}\n"), 0o644)) + paths = append(paths, path) + edits = append(edits, renameEdit{File: name, Line: 2, OldText: "func Old() {}", NewText: "func New() {}"}) + } + _, err := s.indexer.IncrementalReindexPaths(root, paths) + require.NoError(t, err) + held, releasePaths, err := acquireMutationPaths(t.Context(), paths) + require.NoError(t, err) + defer releasePaths() + ctx, ack, _ := s.withEditAcknowledgement(held) + extractor.enabled.Store(true) + unblock := sync.OnceFunc(func() { close(extractor.release) }) + defer unblock() + done := make(chan struct{}) + var writes []*renameFileWrite + var planErr error + go func() { defer close(done); writes, planErr = s.planRenameWrites(ctx, edits, false) }() + t.Cleanup(func() { + unblock() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("owned projection workers remain during cleanup") + } + }) + select { + case <-extractor.entered: + case <-time.After(3 * time.Second): + t.Fatal("first real projection extractor did not enter") + } + parallel := false + select { + case <-extractor.entered: + parallel = true + case <-time.After(time.Second): + } + unblock() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("owned projection workers did not join after release") + } + require.NoError(t, planErr) + require.Len(t, writes, 3) + for _, write := range writes { + require.NotNil(t, write.projection.entry, "bounded parallel preparation must finish each checked candidate, not escape through a timed-out first parse") + } + assert.True(t, parallel, "a second real projection must begin before the first can finish") + assert.EqualValues(t, 2, extractor.max.Load(), "parallelism is bounded rather than one goroutine per file") + ack.finishActual() + ack.finishReply(mcplib.NewToolResultText("planned without writes"), nil) + timing := ack.snapshot().ParseGate + assert.EqualValues(t, 1, timing.Started, "parallel preparation has one actual wall lap, never overlapping additive per-file laps") + assert.EqualValues(t, 1, timing.Completed) + assert.Equal(t, "measured", timing.State) + for _, path := range paths { + raw, readErr := os.ReadFile(path) + require.NoError(t, readErr) + require.Equal(t, "package main\nfunc Old() {}\n", string(raw)) + } +} diff --git a/internal/mcp/source_rename_projection.go b/internal/mcp/source_rename_projection.go new file mode 100644 index 000000000..30adbfac0 --- /dev/null +++ b/internal/mcp/source_rename_projection.go @@ -0,0 +1,89 @@ +package mcp + +import ( + "context" + "fmt" + "sync" +) + +// Parse at most two candidates concurrently, then authenticate metadata after +// their single wall-time lap. The call stack owns preparations only until the +// physical frontier; graphs never enter the mutation ledger or journal state. +func (s *Server) finishRenamePlans(ctx context.Context, writes []*renameFileWrite, allowParseErrors bool, priorError error) ([]*renameFileWrite, error) { + if len(writes) == 0 { + return writes, priorError + } + gated := parseGateEnabled() + var lap editAcknowledgementLap + if gated || s.indexer != nil || s.multiIndexer != nil { + scope := "source_projection" + if gated { + scope = "source_projection_parse_gate" + } + s.noteAcknowledgementParser(ctx, scope) + lap = s.beginAcknowledgementLap(ctx, editAckParseGate) + } + var budgetMu sync.Mutex + var bytes int64 + files := 0 + jobs := make(chan *renameFileWrite) + var workers sync.WaitGroup + for range min(2, len(writes)) { + workers.Go(func() { + for write := range jobs { + candidate, syntax := s.buildFileProjection(ctx, write.AbsPath, write.New) + if gated { + write.Gate = projectedParseGate(write.RelPath, write.Old, write.New, syntax) + } + budgetMu.Lock() + if candidate.entry != nil { + if files >= projectionMaxFiles || bytes+candidate.entry.size > projectionMaxBytes { + candidate.entry = nil + candidate.refusal = "cohort_preparation_limit" + } else { + files++ + bytes += candidate.entry.size + } + } + write.projection = candidate + budgetMu.Unlock() + } + }) + } + for _, write := range writes { + jobs <- write + } + close(jobs) + workers.Wait() + checked := false + for _, write := range writes { + checked = checked || write.Gate.Checked + } + lap.applicable = !gated || checked + lap.finish() + for _, write := range writes { + if write.Gate.Blocked && !allowParseErrors { + return nil, fmt.Errorf("%s", parseGateError(write.RelPath, write.Gate)) + } + write.GateInfo = parseGateInfo(write.Gate, allowParseErrors) + } + if priorError != nil { + return nil, priorError + } + for _, write := range writes { + candidate := write.projection + if candidate.entry == nil { + continue + } + previousSize := candidate.entry.size + s.prepareFileViewEvidence(ctx, candidate, baselineForPreparedWrite(write.AbsPath, write.Old), write.New) + if bytes+candidate.entry.size-previousSize > projectionMaxBytes { + bytes -= previousSize + candidate.entry = nil + candidate.refusal = "cohort_preparation_limit" + } else { + bytes += candidate.entry.size - previousSize + } + } + return writes, nil +} diff --git a/internal/mcp/source_rename_projection_cohort_test.go b/internal/mcp/source_rename_projection_cohort_test.go new file mode 100644 index 000000000..f382f8f79 --- /dev/null +++ b/internal/mcp/source_rename_projection_cohort_test.go @@ -0,0 +1,130 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func TestRenamePublishesNoFileViewBeforeCompletePhysicalFrontier(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + published := make(chan indexer.MutationResult, 2) + for range 2 { + published <- indexer.MutationResult{Reindexed: true} + } + close(published) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + before := pendingFileProjections.snap.Load() + calls := 0 + s.mutationPreCommitHook = func(*mutationCommitRecord) { + calls++ + if calls == 2 { + assert.Same(t, before, pendingFileProjections.snap.Load(), "a second source file is not yet committed: no member may be visible") + raw, err := os.ReadFile(filepath.Join(root, "caller.go")) + require.NoError(t, err) + assert.Contains(t, string(raw), "Renamed", "the first actual write must have completed before the visibility check") + } + } + req := mcplib.CallToolRequest{} + req.Params.Name = "rename_symbol" + req.Params.Arguments = map[string]any{"id": "target.go::Target", "new_name": "Renamed", "format": "json"} + result, err := s.handleRenameSymbol(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + require.Equal(t, 2, calls) + after := pendingFileProjections.snap.Load() + require.NotSame(t, before, after) + for _, name := range []string{"caller.go", "target.go"} { + entry := liveEntry(pendingFileProjections, filepath.Join(root, name)) + require.NotNil(t, entry) + require.Positive(t, entry.commitSeq) + } + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) +} + +func TestRenamePartialCancellationPublishesOnlyActuallyCommittedSubset(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + published := make(chan indexer.MutationResult, 2) + defer close(published) + s.SetWatcher(mutationTestWatcher{done: published}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + writes := make([]*renameFileWrite, 0, 2) + paths := make([]string, 0, 2) + for _, name := range []string{"caller.go", "target.go"} { + path := filepath.Join(root, name) + raw, err := os.ReadFile(path) + require.NoError(t, err) + writes = append(writes, &renameFileWrite{RelPath: name, AbsPath: path, Old: raw, New: []byte(strings.ReplaceAll(string(raw), "Target", "Renamed"))}) + paths = append(paths, path) + } + held, release, err := acquireMutationPaths(ctx, paths) + require.NoError(t, err) + defer release() + seen := 0 + s.mutationPreCommitHook = func(*mutationCommitRecord) { + seen++ + if seen == 2 { + cancel() + } + } + results := s.commitRenameWrites(held, writes, release) + require.Len(t, results, 2) + require.Equal(t, "applied", results[0]["status"]) + require.Equal(t, "failed", results[1]["status"]) + require.Equal(t, mutationDiskNotApplied, results[1]["disk_status"]) + require.Equal(t, true, results[0]["readback_exact"], "cancellation after the first physical effect must retain that exact committed subset") + require.NotNil(t, liveEntry(pendingFileProjections, paths[0])) + require.Nil(t, liveEntry(pendingFileProjections, paths[1])) + first, err := os.ReadFile(paths[0]) + require.NoError(t, err) + require.Equal(t, writes[0].New, first) + second, err := os.ReadFile(paths[1]) + require.NoError(t, err) + require.Equal(t, writes[1].Old, second) +} + +func TestRenameProjectionCapacityRefusalLeavesWholeCacheUnchanged(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + published := make(chan indexer.MutationResult, 2) + for range 2 { + published <- indexer.MutationResult{Reindexed: true} + } + close(published) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + require.NoError(t, pendingFileProjections.publish(&projectionEntry{key: "other-physical-owner", graphPath: "other.go", size: projectionMaxBytes - 32})) + before := pendingFileProjections.snap.Load() + req := mcplib.CallToolRequest{} + req.Params.Name = "rename_symbol" + req.Params.Arguments = map[string]any{"id": "target.go::Target", "new_name": "Renamed", "format": "json"} + result, err := s.handleRenameSymbol(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "cache refusal must preserve the actual disk verdict: %v", result.Content) + require.Same(t, before, pendingFileProjections.snap.Load(), "combined cap refusal must not admit even one rename member") + for _, name := range []string{"caller.go", "target.go"} { + raw, readErr := os.ReadFile(filepath.Join(root, name)) + require.NoError(t, readErr) + require.Contains(t, string(raw), "Renamed") + } + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) +} diff --git a/internal/mcp/source_safe_delete_frontier_test.go b/internal/mcp/source_safe_delete_frontier_test.go new file mode 100644 index 000000000..6f143758b --- /dev/null +++ b/internal/mcp/source_safe_delete_frontier_test.go @@ -0,0 +1,347 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" +) + +func TestPropagateUnknownReferenceNeverInventsForce(t *testing.T) { + for _, explicitForce := range []bool{false, true} { + t.Run(map[bool]string{false: "no explicit force", true: "explicit force retained"}[explicitForce], func(t *testing.T) { + s, root := newSafeDeleteTestServer(t) + path := filepath.Join(root, "foo.go") + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": path + "::Live", "dry_run": false, "propagate": true, "force": explicitForce, "format": "json"} + result, err := s.handleSafeDeleteSymbol(t.Context(), request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + raw, err := os.ReadFile(path) + require.NoError(t, err) + if explicitForce { + require.NotContains(t, string(raw), "func Live()") + } else { + require.Contains(t, string(raw), "func Live()", "an unlocated reference cannot disappear merely because the propagation plan omitted it") + } + }) + } +} + +func TestPropagateMustNotRemoveAnotherStatementOnTheSameLine(t *testing.T) { + s, root := setupMoveInlineRepo(t, map[string]string{ + "del.go": "package p\nfunc Target() {}\n", + "caller.go": "package p\nfunc Caller() {\n Target(); Keep()\n}\nfunc Keep() {}\n", + }) + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "del.go::Target", "dry_run": false, "propagate": true, "format": "json"} + result, err := s.handleSafeDeleteSymbol(t.Context(), request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + raw, err := os.ReadFile(filepath.Join(root, "caller.go")) + require.NoError(t, err) + require.Contains(t, string(raw), "Target(); Keep()", "a source line with another statement requires manual patching, not whole-line deletion") + raw, err = os.ReadFile(filepath.Join(root, "del.go")) + require.NoError(t, err) + require.Contains(t, string(raw), "func Target()") +} + +func TestPropagateLaterFailureRetainsAppliedPatchAndPublication(t *testing.T) { + s, root := setupMoveInlineRepo(t, map[string]string{ + "del.go": "package p\nfunc Target() {}\n", + "a.go": "package p\nfunc First() {\n Target()\n}\n", + "z.go": "package p\nfunc Last() {\n Target()\n}\n", + }) + publications := make(chan indexer.MutationResult) + defer close(publications) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Nanosecond + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.mutationPreCommitHook = func(record *mutationCommitRecord) { + if filepath.Base(record.absPath) == "z.go" { + cancel() + } + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "del.go::Target", "dry_run": false, "propagate": true, "format": "json"} + result, err := s.handleSafeDeleteSymbol(ctx, request) + require.NoError(t, err) + first := filepath.Join(root, "a.go") + raw, err := os.ReadFile(first) + require.NoError(t, err) + require.NotContains(t, string(raw), "Target()", "prove the first physical caller patch committed before the second refused") + require.Contains(t, toolResultText(result), "patches_applied", "the error path must retain actual completed source effects") + require.Contains(t, toolResultText(result), "a.go") + select { + case admitted := <-watcher.announced: + require.Equal(t, []string{first}, admitted) + default: + t.Fatal("a later failure abandoned publication of the first physical caller patch") + } + for _, name := range []string{"z.go", "del.go"} { + raw, err := os.ReadFile(filepath.Join(root, name)) + require.NoError(t, err) + require.Contains(t, string(raw), "Target()") + } +} + +func TestPropagateKnownAndUnknownReferenceKeepsUnknownAuthority(t *testing.T) { + s, g, root := setupDeleteServer(t) + g.AddEdge(&graph.Edge{From: "caller.go::Caller", To: "del.go::Target", Kind: graph.EdgeReferences, FilePath: "caller.go"}) + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "del.go::Target", "dry_run": false, "propagate": true, "format": "json"} + result, err := s.handleSafeDeleteSymbol(t.Context(), request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + raw, err := os.ReadFile(filepath.Join(root, "del.go")) + require.NoError(t, err) + require.True(t, strings.Contains(string(raw), "func Target()"), "removing one exact call cannot certify an unknown reference as removed") +} + +func TestSafeDeleteRevalidatesAuthorityAfterWholeFrontierLock(t *testing.T) { + for _, mode := range []string{"published reference", "unpublished reference", "cascade member gains caller"} { + t.Run(mode, func(t *testing.T) { + rootBody := "package p\nfunc Root() {}\n" + if mode == "cascade member gains caller" { + rootBody = "package p\nfunc Root() { Child() }\n" + } + s, root := setupMoveInlineRepo(t, map[string]string{ + "root.go": rootBody, "child.go": "package p\nfunc Child() {}\n", "outside.go": "package p\nfunc Outside() {}\n", + }) + locked := filepath.Join(root, "root.go") + if mode == "cascade member gains caller" { + locked = filepath.Join(root, "child.go") + } + _, release, err := acquireMutationPath(t.Context(), locked) + require.NoError(t, err) + var once sync.Once + unlock := func() { once.Do(release) } + defer unlock() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "root.go::Root", "dry_run": false, "format": "json"} + if mode == "cascade member gains caller" { + request.Params.Arguments.(map[string]any)["cascade"] = "apply" + } + var publication chan indexer.MutationResult + if mode == "unpublished reference" { + publication = make(chan indexer.MutationResult, 1) + s.SetWatcher(mutationTestWatcher{done: publication}) + } + type response struct { + result *mcplib.CallToolResult + err error + } + finished := make(chan response, 1) + go func() { result, err := s.handleSafeDeleteSymbol(ctx, request); finished <- response{result, err} }() + joined := false + defer func() { + cancel() + unlock() + if !joined { + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned delete did not join during cleanup") + } + } + }() + waitForMutationRefs(t, locked, 2) + callee := "Root" + if mode == "cascade member gains caller" { + callee = "Child" + } + outside := filepath.Join(root, "outside.go") + writeCtx, releaseWriter, err := acquireMutationPath(t.Context(), outside) + require.NoError(t, err) + defer releaseWriter() + _, err = s.commitFileMutation(writeCtx, "write_file", "", "", "outside.go", outside, []byte("package p\nfunc Outside() { "+callee+"() }\n"), 0o644) + require.NoError(t, err, "a real independent source commit lands while the delete is queued") + var receipt *mutationReceipt + if mode == "unpublished reference" { + admission := s.admitSourceMutations(writeCtx, outside) + receipt = admission.receipt + require.NotNil(t, receipt) + } else { + s.graph.AddEdge(&graph.Edge{From: "outside.go::Outside", To: strings.ToLower(callee) + ".go::" + callee, Kind: graph.EdgeCalls, FilePath: "outside.go", Line: 2}) + } + releaseWriter() + unlock() + var actual response + select { + case actual = <-finished: + joined = true + case <-time.After(3 * time.Second): + t.Fatal("delete did not return after the path frontier was released") + } + if receipt != nil { + publication <- indexer.MutationResult{RequestedGeneration: receipt.generation, AppliedGeneration: receipt.generation, Reindexed: true} + close(publication) + <-receipt.done + } + require.NoError(t, actual.err) + require.False(t, actual.result.IsError, "%v", actual.result.Content) + raw, err := os.ReadFile(filepath.Join(root, "root.go")) + require.NoError(t, err) + require.Contains(t, string(raw), "func Root()", "new reference/closure authority must be checked after the final union admission, before any delete") + if mode == "cascade member gains caller" { + raw, err = os.ReadFile(filepath.Join(root, "child.go")) + require.NoError(t, err) + require.Contains(t, string(raw), "func Child()", "a newly referenced cascade member must not be deleted under an obsolete closure") + } + require.Contains(t, toolResultText(actual.result), "source_authority_changed") + }) + } +} + +func TestPropagateDeleteAdmitsOnlyTheFinalCompleteSourceFrontier(t *testing.T) { + for _, sameFile := range []bool{false, true} { + t.Run(map[bool]string{false: "two files", true: "same physical file"}[sameFile], func(t *testing.T) { + files := map[string]string{"del.go": "package p\nfunc Target() {}\n", "caller.go": "package p\nfunc Caller() {\n Target()\n}\n"} + if sameFile { + files = map[string]string{"del.go": "package p\nfunc Target() {}\nfunc Caller() {\n Target()\n}\n"} + } + s, root := setupMoveInlineRepo(t, files) + publications := make(chan indexer.MutationResult, len(files)) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + paths := []string{filepath.Join(root, "del.go")} + if !sameFile { + paths = append(paths, filepath.Join(root, "caller.go")) + } + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + check, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPaths(check, paths) + if err == nil { + release() + } + waiting <- err + return true + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "del.go::Target", "dry_run": false, "propagate": true, "format": "json"} + ctx, note := withMutationCommitNote(t.Context()) + ctx, cancel := context.WithCancel(ctx) + finished := make(chan struct{}) + complete := sync.OnceFunc(func() { + for range len(files) { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + } + close(publications) + }) + var result *mcplib.CallToolResult + var resultErr error + go func() { defer close(finished); result, resultErr = s.handleSafeDeleteSymbol(ctx, request) }() + defer func() { + cancel() + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned propagate/delete did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.ElementsMatch(t, paths, admitted) + case <-finished: + t.Fatalf("propagate/delete returned before admitting its final frontier: %v %v", resultErr, result.Content) + case <-time.After(3 * time.Second): + t.Fatal("propagate/delete never admitted its final frontier") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.NotContains(t, string(raw), "Target()", "both caller removal and definition delete precede the first admission") + } + select { + case err := <-waiting: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("source locks were not released before publication wait") + } + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("published operation did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + snapshots := note.verdict().Snapshots + require.Len(t, snapshots, 2, "the caller patch and the definition deletion are real physical writes") + if sameFile { + require.EqualValues(t, 1, watcher.points.Load()) + require.Zero(t, watcher.sets.Load()) + recorded := 0 + for _, snapshot := range snapshots { + if snapshot.GraphRecorded { + recorded++ + } + } + require.Equal(t, 1, recorded, "only final bytes may receive publication success; the intermediate physical receipt stays historical") + } else { + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) + } + }) + } +} + +func TestPropagateChangedSourcePreviewNeverAuthorizesApply(t *testing.T) { + s, root := newRelationFixture(t) + caller := writeUnindexed(t, root, "caller.go", "package p\n\nfunc Caller() {\n\tTarget()\n}\n\nfunc More() {}\n") + pendingFreshnessReceipt(s, "preview-caller", "", caller, 1) + target := filepath.Join(root, "target.go") + beforeCaller, err := os.ReadFile(caller) + require.NoError(t, err) + beforeTarget, err := os.ReadFile(target) + require.NoError(t, err) + id := targetID(t, s) + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": id, "propagate": true, "format": "json"} + ctx, note := withMutationCommitNote(t.Context()) + preview, err := s.handleSafeDeleteSymbol(ctx, request) + require.NoError(t, err) + require.False(t, preview.IsError, "%v", preview.Content) + payload := extractTextResult(t, preview) + require.Equal(t, "propagation_plan", payload["status"]) + require.EqualValues(t, 0, payload["manual"]) + require.Equal(t, true, payload["source_revalidation_required"]) + require.Contains(t, payload["hint"], "not write authorization") + require.Empty(t, note.verdict().Snapshots) + // A private preview row is also not an authorization token for the writer. + plan := s.buildPropagationPreview(ctx, s.readerFor(ctx), s.readerFor(ctx).GetNode(id)) + applied, failed, err := s.applyRemoveLinePatches(ctx, plan) + require.NoError(t, err) + require.Empty(t, applied) + require.Len(t, failed, 1) + require.Equal(t, "source_unverified", failed[0].Reason) + require.Empty(t, note.verdict().Snapshots) + request.Params.Arguments.(map[string]any)["dry_run"] = false + result, err := s.handleSafeDeleteSymbol(ctx, request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + require.Equal(t, "propagation_blocked_manual", extractTextResult(t, result)["status"]) + require.Empty(t, note.verdict().Snapshots) + afterCaller, err := os.ReadFile(caller) + require.NoError(t, err) + afterTarget, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, beforeCaller, afterCaller) + require.Equal(t, beforeTarget, afterTarget) +} diff --git a/internal/mcp/source_write_census_test.go b/internal/mcp/source_write_census_test.go new file mode 100644 index 000000000..eada8b4b5 --- /dev/null +++ b/internal/mcp/source_write_census_test.go @@ -0,0 +1,153 @@ +package mcp + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// sourceWriteChokePoint is the one function allowed to write or remove source +// files in this package: it refuses dead requests and records the commit, so +// every source write is ordered against the other writers of the same file. +const sourceWriteChokePoint = "Server.commitFileMutation" + +// sourceWriteCensusEnforced turns the census from a report into a gate once +// every source writer routes through the choke point. +const sourceWriteCensusEnforced = true + +// nonSourceWriters may reference a file writer without the choke point: they +// write tool outputs, transaction journals and spill files, or they are a +// write primitive themselves (references to those still count). +var nonSourceWriters = map[string]string{ + "durableAtomicWriteFile": "the durable write primitive", + "Server.persistBatchManifest": "batch transaction manifest", + "Server.prepareBatchJournal": "batch transaction backups", + "Server.batchDurability": "selects the durable source primitive", + "Server.cleanupBatchBackups": "removes transaction backups only", + "symbolPageStorage.close": "symbol page spill directory", + "symbolPageStorage.spill": "symbol page spill directory", + "Server.handleExportGraph": "export output path", + "Server.handleGenerateSkill": "skill files", + "writeWikiFile": "wiki output", +} + +// sourceWriteSink names the file writer n refers to, called or passed as a +// value, or returns "". +func sourceWriteSink(n ast.Node) string { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return "" + } + switch x := sel.X.(type) { + case *ast.Ident: + switch { + case x.Name == "os" && (sel.Sel.Name == "WriteFile" || sel.Sel.Name == "Remove"): + return "os." + sel.Sel.Name + case x.Name == "agents" && sel.Sel.Name == "AtomicWriteFile": + return "agents.AtomicWriteFile" + case x.Name == "platform" && sel.Sel.Name == "RemoveFile": + return "platform.RemoveFile" + case sel.Sel.Name == "writeFile" || sel.Sel.Name == "removeFile": + return "durable." + sel.Sel.Name + } + case *ast.CallExpr: + if inner, ok := x.Fun.(*ast.SelectorExpr); ok && inner.Sel.Name == "batchDurability" && (sel.Sel.Name == "writeFile" || sel.Sel.Name == "removeFile") { + return "batchDurability()." + sel.Sel.Name + } + } + return "" +} + +// funcKey names a declaration as Type.method or func. +func funcKey(fn *ast.FuncDecl) string { + if fn.Recv == nil || len(fn.Recv.List) == 0 { + return fn.Name.Name + } + recv := fn.Recv.List[0].Type + if star, ok := recv.(*ast.StarExpr); ok { + recv = star.X + } + if ident, ok := recv.(*ast.Ident); ok { + return ident.Name + "." + fn.Name.Name + } + return fn.Name.Name +} + +// sourceWriteBypasses lists every file-writer reference made outside the +// choke point and the non-source writers, as "file: func -> sink". +func sourceWriteBypasses(files map[string]*ast.File) []string { + var out []string + for name, file := range files { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + key := funcKey(fn) + if _, allowed := nonSourceWriters[key]; allowed || key == sourceWriteChokePoint { + continue + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + if sink := sourceWriteSink(n); sink != "" { + out = append(out, fmt.Sprintf("%s: %s -> %s", name, key, sink)) + } + return true + }) + } + } + sort.Strings(out) + return out +} + +// TestSourceWriteChokePointCensus lists the source writers that bypass the +// choke point. It reports until every writer is routed, then gates. +func TestSourceWriteChokePointCensus(t *testing.T) { + paths, err := filepath.Glob("*.go") + require.NoError(t, err) + fset := token.NewFileSet() + files := make(map[string]*ast.File) + for _, path := range paths { + if strings.HasSuffix(path, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + require.NoError(t, err) + files[path] = file + } + bypasses := sourceWriteBypasses(files) + for _, bypass := range bypasses { + t.Log("source write outside the choke point: " + bypass) + } + if sourceWriteCensusEnforced { + require.Empty(t, bypasses, "route these writers through %s", sourceWriteChokePoint) + } + + // The census must see a writer passed as a value as well as a call, and + // must not count the choke point or its callers. + fixture, err := parser.ParseFile(fset, "fixture.go", `package mcp +func (s *Server) commitFileMutation() { agents.AtomicWriteFile() } +func (s *Server) handleRouted() { s.commitFileMutation() } +func (s *Server) handleFixture() { + write := s.batchDurability().writeFile + _ = write + os.Remove("x") + ops := s.batchDurability() + _ = ops.removeFile + platform.RemoveFile("x") +} +`, parser.SkipObjectResolution) + require.NoError(t, err) + require.Equal(t, []string{ + "fixture.go: Server.handleFixture -> batchDurability().writeFile", + "fixture.go: Server.handleFixture -> durable.removeFile", + "fixture.go: Server.handleFixture -> os.Remove", + "fixture.go: Server.handleFixture -> platform.RemoveFile", + }, sourceWriteBypasses(map[string]*ast.File{"fixture.go": fixture})) +} diff --git a/internal/mcp/symbol_source_line_shift_test.go b/internal/mcp/symbol_source_line_shift_test.go new file mode 100644 index 000000000..90907a764 --- /dev/null +++ b/internal/mcp/symbol_source_line_shift_test.go @@ -0,0 +1,25 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestSymbolSourceSingleRepoWatcherLineShift reads a symbol whose file was +// edited, shifting its lines, while the watcher that owns freshness has not +// reindexed the file yet. The answer must be the symbol's current body and +// range, never the old range cut out of the new bytes. +func TestSymbolSourceSingleRepoWatcherLineShift(t *testing.T) { + srv, dir := setupTestServer(t) + srv.SetWatcher(mutationTestWatcher{}) + shiftTestServerMain(t, dir) + + resp := extractTextResult(t, callTool(t, srv, "get_symbol_source", map[string]any{ + "id": "main.go::helper", + "context_lines": float64(0), + })) + require.Equal(t, "func helper() {}", resp["source"]) + require.EqualValues(t, 13, resp["start_line"]) + require.EqualValues(t, 13, resp["end_line"]) +} diff --git a/internal/mcp/symbol_span.go b/internal/mcp/symbol_span.go new file mode 100644 index 000000000..b3dea5816 --- /dev/null +++ b/internal/mcp/symbol_span.go @@ -0,0 +1,71 @@ +package mcp + +import ( + "errors" + "strings" +) + +var ( + errSymbolRangeOutsideFile = errors.New("symbol line range exceeds file length") + errEditFragmentNotFound = errors.New("old_source not found within the symbol") + errEditFragmentRepeated = errors.New("old_source appears more than once within the symbol; provide a larger fragment") +) + +// symbolEdit locates old_source inside a symbol: byte offsets into the file +// content, and whether the match bridged a line-ending difference, in which +// case the replacement must adopt the region's own terminators. +type symbolEdit struct { + start, end int + normalized bool +} + +// locateSymbolEdit finds oldSource inside the symbol on lines +// [startLine, endLine] of content: within the symbol itself, or else within +// the symbol plus the comment and blank lines directly above it, since agents +// often quote a symbol's doc comment. The fragment must occur exactly once in +// the region it matches, so a fragment repeated elsewhere in the file never +// pulls the edit into another symbol. The range must describe content: take +// it from nodeLinesIn over the same bytes, never from a stored node alone. +func locateSymbolEdit(content string, startLine, endLine int, oldSource string) (symbolEdit, error) { + lines := strings.Split(content, "\n") + if startLine < 1 || endLine < startLine || endLine > len(lines) { + return symbolEdit{}, errSymbolRangeOutsideFile + } + offsets := make([]int, endLine) + for i := 1; i < endLine; i++ { + offsets[i] = offsets[i-1] + len(lines[i-1]) + 1 + } + end := offsets[endLine-1] + len(lines[endLine-1]) + from := startLine - 1 + matches := findEOLMatches(content[offsets[from]:end], oldSource) + if matches.count == 0 { + above := from + for above > 0 && isDocOrBlankLine(lines[above-1]) { + above-- + } + if above < from { + from = above + matches = findEOLMatches(content[offsets[from]:end], oldSource) + } + } + switch { + case matches.count == 0: + return symbolEdit{}, errEditFragmentNotFound + case matches.count > 1: + return symbolEdit{}, errEditFragmentRepeated + } + span := matches.spans[0] + return symbolEdit{ + start: offsets[from] + span.start, + end: offsets[from] + span.end, + normalized: matches.normalized, + }, nil +} + +// isDocOrBlankLine reports whether line can belong to the doc comment block +// above a declaration. +func isDocOrBlankLine(line string) bool { + trimmed := strings.TrimSpace(line) + return trimmed == "" || strings.HasPrefix(trimmed, "//") || + strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") +} diff --git a/internal/mcp/symbol_span_test.go b/internal/mcp/symbol_span_test.go new file mode 100644 index 000000000..1a7145c44 --- /dev/null +++ b/internal/mcp/symbol_span_test.go @@ -0,0 +1,94 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/query" +) + +// repeatedFragmentSource has the same body in two functions, so a fragment +// matched against the wrong range edits the wrong function. +const repeatedFragmentSource = "package sample\n\nfunc first() error {\n\treturn nil\n}\n\n// second documents itself.\nfunc second() error {\n\treturn nil\n}\n" + +func TestLocateSymbolEdit(t *testing.T) { + src := repeatedFragmentSource + secondAt := strings.Index(src, "func second") + crlf := strings.ReplaceAll(src, "\n", "\r\n") + + cases := []struct { + name string + content string + start, end int + fragment string + want string // the bytes the span covers + after int // the span starts at or after this offset + normalized bool + err error + }{ + {name: "first", content: src, start: 3, end: 5, fragment: "return nil", want: "return nil"}, + {name: "second", content: src, start: 8, end: 10, fragment: "return nil", want: "return nil", after: secondAt}, + {name: "doc comment above", content: src, start: 8, end: 10, fragment: "// second documents itself.\nfunc second", want: "// second documents itself.\nfunc second"}, + {name: "crlf region", content: crlf, start: 8, end: 10, fragment: "error {\n\treturn nil", want: "error {\r\n\treturn nil", normalized: true}, + {name: "repeated inside the symbol", content: "package p\n\nfunc both() {\n\tx++\n\tx++\n}\n", start: 3, end: 6, fragment: "x++", err: errEditFragmentRepeated}, + {name: "outside the symbol", content: src, start: 3, end: 5, fragment: "func second", err: errEditFragmentNotFound}, + {name: "range past the end", content: src, start: 8, end: 20, fragment: "return nil", err: errSymbolRangeOutsideFile}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := locateSymbolEdit(tc.content, tc.start, tc.end, tc.fragment) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, tc.content[got.start:got.end]) + require.GreaterOrEqual(t, got.start, tc.after) + require.Equal(t, tc.normalized, got.normalized) + }) + } +} + +// TestSymbolEditFollowsLineShiftPastRepeatedFragment covers the edit that +// lands in the wrong function: another session's edit shifted the file, so +// the stored range of second now covers first's body, where the fragment +// also occurs exactly once. Resolving the range against the bytes read under +// the lock keeps the edit in second. +func TestSymbolEditFollowsLineShiftPastRepeatedFragment(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sample.go") + src := strings.Replace(repeatedFragmentSource, "// second documents itself.\n", "", 1) + require.NoError(t, os.WriteFile(path, []byte(src), 0o644)) + g := graph.New() + idx := indexer.New(g, testRegistry(), config.Default().Index, zap.NewNop()) + _, err := idx.Index(dir) + require.NoError(t, err) + srv := NewServer(query.NewEngine(g), g, idx, nil, zap.NewNop(), nil) + ctx := context.Background() + + node := srv.engineFor(ctx).GetSymbol("sample.go::second") + require.NotNil(t, node) + require.Equal(t, [2]int{7, 9}, [2]int{node.StartLine, node.EndLine}) + + shifted := strings.Replace(src, "func first", "// a\n// b\n// c\nfunc first", 1) + secondAt := strings.Index(shifted, "func second") + stale, err := locateSymbolEdit(shifted, node.StartLine, node.EndLine, "\treturn nil") + require.NoError(t, err) + require.Less(t, stale.start, secondAt, "the stored range alone lands the edit in first") + + start, end, err := srv.nodeLinesIn(ctx, node, []byte(shifted)) + require.NoError(t, err) + require.Equal(t, [2]int{10, 12}, [2]int{start, end}) + edit, err := locateSymbolEdit(shifted, start, end, "\treturn nil") + require.NoError(t, err) + require.Greater(t, edit.start, secondAt) +} diff --git a/internal/mcp/symbol_write_runtime_test.go b/internal/mcp/symbol_write_runtime_test.go new file mode 100644 index 000000000..2a3802e93 --- /dev/null +++ b/internal/mcp/symbol_write_runtime_test.go @@ -0,0 +1,76 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +func TestSymbolWritersUseCurrentDeclarationAfterLineShift(t *testing.T) { + for _, tool := range []string{"edit_symbol", "safe_delete_symbol"} { + t.Run(tool, func(t *testing.T) { + original := strings.Replace(repeatedFragmentSource, "// second documents itself.\n", "", 1) + srv, root := setupMoveInlineRepo(t, map[string]string{"sample.go": original}) + srv.SetWatcher(mutationTestWatcher{}) + shifted := strings.Replace(original, "func first", "// a\n// b\n// c\nfunc first", 1) + path := filepath.Join(root, "sample.go") + require.NoError(t, os.WriteFile(path, []byte(shifted), 0o644)) + req := editFileRequest(map[string]any{ + "id": "sample.go::second", "old_source": "\treturn nil", "new_source": "\treturn errSecond", "force": true, "dry_run": false, "format": "json", + }) + req.Params.Name = tool + var result *mcp.CallToolResult + var callErr error + if tool == "edit_symbol" { + ctx, ack, _ := srv.withEditAcknowledgement(t.Context()) + defer ack.finishActual() + result, callErr = srv.handleEditSymbol(ctx, req) + ack.finishReply(result, callErr) + ack.finishActual() + observed := ack.snapshot() + require.EqualValues(t, 2, observed.ParseGate.Started, "actual relocation extraction and committed projection each own one observed parse") + require.EqualValues(t, 2, observed.ParseGate.Completed) + require.Equal(t, "mixed_observed_parser_paths", observed.ParserScope) + require.True(t, observed.LapsDisjoint) + } else { + result, callErr = srv.handleSafeDeleteSymbol(t.Context(), req) + } + require.NoError(t, callErr) + require.False(t, result.IsError, "%v", result.Content) + current, err := os.ReadFile(path) + require.NoError(t, err) + first, _, found := strings.Cut(string(current), "func second") + require.Contains(t, first, "func first") + require.Contains(t, first, "\treturn nil", "the repeated fragment in the other declaration must survive") + if tool == "edit_symbol" { + require.True(t, found) + require.NotContains(t, first, "errSecond") + require.Contains(t, string(current), "\treturn errSecond") + } else { + require.False(t, found, "delete must remove the current second declaration") + } + }) + } +} + +func TestSafeDeleteRemovedDeclarationRefusesBeforeSourceWrite(t *testing.T) { + original := strings.Replace(repeatedFragmentSource, "// second documents itself.\n", "", 1) + srv, root := setupMoveInlineRepo(t, map[string]string{"sample.go": original}) + srv.SetWatcher(mutationTestWatcher{}) + removed := strings.Replace(original, "func second() error {\n\treturn nil\n}", "// declaration removed\n\n\n", 1) + require.NotEqual(t, original, removed) + path := filepath.Join(root, "sample.go") + require.NoError(t, os.WriteFile(path, []byte(removed), 0o644)) + result, callErr := srv.handleSafeDeleteSymbol(t.Context(), editFileRequest(map[string]any{"id": "sample.go::second", "force": true, "dry_run": false, "format": "json"})) + require.NoError(t, callErr) + require.True(t, result.IsError, "a removed current declaration cannot donate its old span: %v", result.Content) + current, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, removed, string(current)) + _, committed := srv.mutationCommits.recentForPath(path) + require.False(t, committed, "refusal must precede physical source authority") +} diff --git a/internal/mcp/testdata/search_golden/symbols_compact.txt b/internal/mcp/testdata/search_golden/symbols_compact.txt new file mode 100644 index 000000000..735834be7 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_compact.txt @@ -0,0 +1,2 @@ +method Server.HandleRequest server/server.go:14 (in Server) +method Server.HandleHealth server/server.go:22 (in Server) diff --git a/internal/mcp/testdata/search_golden/symbols_concept.txt b/internal/mcp/testdata/search_golden/symbols_concept.txt new file mode 100644 index 000000000..990bf1ed4 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_concept.txt @@ -0,0 +1 @@ +{"query_class":"concept","results":[{"absolute_file_path":"$ROOT/config/config.go","doc":"LoadConfig reads the settings file at path.","file_path":"config/config.go","id":"config/config.go::LoadConfig","kind":"function","name":"LoadConfig","start_line":10,"visibility":"public"},{"absolute_file_path":"$ROOT/config/config.go","doc":"Config holds the server settings.","file_path":"config/config.go","id":"config/config.go::Config","kind":"type","name":"Config","start_line":4,"type_flavor":"struct","visibility":"public"},{"absolute_file_path":"$ROOT/server/server.go","doc":"NewServer builds a server from cfg.","file_path":"server/server.go","id":"server/server.go::NewServer","kind":"function","name":"NewServer","start_line":11,"visibility":"public"},{"file_path":"","id":"dep::example.com/app/config::LoadConfig","kind":"function","name":"LoadConfig","start_line":0},{"absolute_file_path":"$ROOT/config/config.go","doc":"Validate reports whether the settings are usable.","enclosing":"Config","enclosing_id":"config/config.go::Config","file_path":"config/config.go","id":"config/config.go::Config.Validate","kind":"method","name":"Validate","start_line":15,"visibility":"public"},{"absolute_file_path":"$ROOT/external-call::dep:example.com/app/config","file_path":"external-call::dep:example.com/app/config","id":"external-call::dep::example.com/app/config","kind":"module","name":"example.com/app/config","start_line":0},{"absolute_file_path":"$ROOT/server/server.go","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.cfg","kind":"field","name":"cfg","start_line":7,"visibility":"package"},{"file_path":"","id":"module::go:example.com/app/config","kind":"module","name":"config","start_line":0},{"absolute_file_path":"$ROOT/config/config.go","enclosing":"Config","enclosing_id":"config/config.go::Config","file_path":"config/config.go","id":"config/config.go::Config.Port","kind":"field","name":"Port","start_line":5,"visibility":"public"},{"absolute_file_path":"$ROOT/config/config.go","file_path":"config/config.go","id":"config/config.go::LoadConfig#param:path","kind":"param","name":"path","start_line":10},{"absolute_file_path":"$ROOT/config/config.go","enclosing":"Config","enclosing_id":"config/config.go::Config","file_path":"config/config.go","id":"config/config.go::Config.Host","kind":"field","name":"Host","start_line":6,"visibility":"public"}],"total":11,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_exact.txt b/internal/mcp/testdata/search_golden/symbols_exact.txt new file mode 100644 index 000000000..ef693503e --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_exact.txt @@ -0,0 +1 @@ +{"query_class":"symbol","results":[{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleRequest serves one request.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleRequest","kind":"method","name":"HandleRequest","start_line":14,"visibility":"public"}],"total":1,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_gcx.txt b/internal/mcp/testdata/search_golden/symbols_gcx.txt new file mode 100644 index 000000000..c4140762b --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_gcx.txt @@ -0,0 +1,4 @@ +GCX1 tool=search_symbols fields=id,kind,name,path,path_abs,line,sig,enclosing,is_test,test_role,test_runner,type_flavor,ui_component total=2 truncated=false +# 2 result(s) +server/server.go::Server.HandleRequest method Server.HandleRequest server/server.go $ROOT/server/server.go 14 func ((s *Server)) HandleRequest(path string) string Server false +server/server.go::Server.HandleHealth method Server.HandleHealth server/server.go $ROOT/server/server.go 22 func ((s *Server)) HandleHealth() string Server false diff --git a/internal/mcp/testdata/search_golden/symbols_json.txt b/internal/mcp/testdata/search_golden/symbols_json.txt new file mode 100644 index 000000000..1093f71f1 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_json.txt @@ -0,0 +1 @@ +{"query_class":"symbol","results":[{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleRequest serves one request.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleRequest","kind":"method","name":"HandleRequest","start_line":14,"visibility":"public"},{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleHealth answers the health probe.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleHealth","kind":"method","name":"HandleHealth","start_line":22,"visibility":"public"}],"total":2,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_kind.txt b/internal/mcp/testdata/search_golden/symbols_kind.txt new file mode 100644 index 000000000..879ab0d89 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_kind.txt @@ -0,0 +1 @@ +{"query_class":"symbol","results":[{"absolute_file_path":"$ROOT/config/config.go","doc":"Config holds the server settings.","file_path":"config/config.go","id":"config/config.go::Config","kind":"type","name":"Config","start_line":4,"type_flavor":"struct","visibility":"public"}],"total":1,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_limit.txt b/internal/mcp/testdata/search_golden/symbols_limit.txt new file mode 100644 index 000000000..b56450ce4 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_limit.txt @@ -0,0 +1 @@ +{"next_cursor":"$CURSOR","query_class":"symbol","results":[{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleRequest serves one request.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleRequest","kind":"method","name":"HandleRequest","start_line":14,"visibility":"public"}],"total":2,"truncated":true} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_missing.txt b/internal/mcp/testdata/search_golden/symbols_missing.txt new file mode 100644 index 000000000..0233d92f2 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_missing.txt @@ -0,0 +1 @@ +{"query_class":"symbol","results":[],"total":0,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_prefix.txt b/internal/mcp/testdata/search_golden/symbols_prefix.txt new file mode 100644 index 000000000..1093f71f1 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_prefix.txt @@ -0,0 +1 @@ +{"query_class":"symbol","results":[{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleRequest serves one request.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleRequest","kind":"method","name":"HandleRequest","start_line":14,"visibility":"public"},{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleHealth answers the health probe.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleHealth","kind":"method","name":"HandleHealth","start_line":22,"visibility":"public"}],"total":2,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_toon.txt b/internal/mcp/testdata/search_golden/symbols_toon.txt new file mode 100644 index 000000000..3960b6123 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_toon.txt @@ -0,0 +1,5 @@ +results[2]{id,kind,name,file_path,start_line,enclosing,enclosing_id,is_test,test_role}: + "server/server.go::Server.HandleRequest",method,HandleRequest,server/server.go,14,Server,"server/server.go::Server",false,"" + "server/server.go::Server.HandleHealth",method,HandleHealth,server/server.go,22,Server,"server/server.go::Server",false,"" +total: 2 +truncated: false \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/text_limit.txt b/internal/mcp/testdata/search_golden/text_limit.txt new file mode 100644 index 000000000..3c40f5796 --- /dev/null +++ b/internal/mcp/testdata/search_golden/text_limit.txt @@ -0,0 +1 @@ +{"_limit_applied":2,"_truncated_by_limit":true,"count":2,"count_is_exact":false,"matches":[{"path":"config/config.go","line":1,"text":"package config"},{"path":"main.go","line":4,"text":"\t\"example.com/app/config\""}],"query":"config","truncation_note":"the search stopped at `limit`, so `count` is a floor rather than a total and the matches are a prefix of the real result set. Raise `limit` (or the GORTEX_SEARCH_TEXT_MAX_LIMIT ceiling) to widen. A `path` filter (when supplied) is applied before this cut, so the floor is a floor of the scoped result set, not of the global one."} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/text_literal.txt b/internal/mcp/testdata/search_golden/text_literal.txt new file mode 100644 index 000000000..7e5a24d68 --- /dev/null +++ b/internal/mcp/testdata/search_golden/text_literal.txt @@ -0,0 +1 @@ +{"count":2,"matches":[{"path":"server/server.go","line":18,"text":"\treturn \"handled \" + path","symbol_id":"server/server.go::Server.HandleRequest","symbol_name":"HandleRequest"},{"path":"server/server.go","line":22,"text":"func (s *Server) HandleHealth() string { return \"handled health\" }","symbol_id":"server/server.go::Server.HandleHealth","symbol_name":"HandleHealth"}],"query":"handled"} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/text_missing.txt b/internal/mcp/testdata/search_golden/text_missing.txt new file mode 100644 index 000000000..bfddcc24e --- /dev/null +++ b/internal/mcp/testdata/search_golden/text_missing.txt @@ -0,0 +1 @@ +{"count":0,"matches":[],"query":"no such literal anywhere"} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/text_path.txt b/internal/mcp/testdata/search_golden/text_path.txt new file mode 100644 index 000000000..f7478666b --- /dev/null +++ b/internal/mcp/testdata/search_golden/text_path.txt @@ -0,0 +1 @@ +{"count":4,"matches":[{"path":"server/server.go","line":3,"text":"import \"example.com/app/config\""},{"path":"server/server.go","line":7,"text":"\tcfg *config.Config","symbol_id":"server/server.go::Server","symbol_name":"Server"},{"path":"server/server.go","line":11,"text":"func NewServer(cfg *config.Config) *Server { return \u0026Server{cfg: cfg} }","symbol_id":"server/server.go::NewServer","symbol_name":"NewServer"},{"path":"server/server.go","line":16,"text":"\t\treturn \"invalid config\"","symbol_id":"server/server.go::Server.HandleRequest","symbol_name":"HandleRequest"}],"query":"config"} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/text_regexp.txt b/internal/mcp/testdata/search_golden/text_regexp.txt new file mode 100644 index 000000000..e78b9a75e --- /dev/null +++ b/internal/mcp/testdata/search_golden/text_regexp.txt @@ -0,0 +1 @@ +{"count":4,"matches":[{"path":"main.go","line":11,"text":"\t_ = srv.HandleRequest(\"/\")","symbol_id":"main.go::main","symbol_name":"main"},{"path":"main.go","line":12,"text":"\t_ = srv.HandleHealth()","symbol_id":"main.go::main","symbol_name":"main"},{"path":"server/server.go","line":14,"text":"func (s *Server) HandleRequest(path string) string {","symbol_id":"server/server.go::Server.HandleRequest","symbol_name":"HandleRequest"},{"path":"server/server.go","line":22,"text":"func (s *Server) HandleHealth() string { return \"handled health\" }","symbol_id":"server/server.go::Server.HandleHealth","symbol_name":"HandleHealth"}],"query":"Handle[A-Z][a-z]+\\("} \ No newline at end of file diff --git a/internal/mcp/tool_deadline.go b/internal/mcp/tool_deadline.go index bb8171f87..4ef231d5c 100644 --- a/internal/mcp/tool_deadline.go +++ b/internal/mcp/tool_deadline.go @@ -537,6 +537,17 @@ func (s *Server) boundToolHandler(h mcpserver.ToolHandlerFunc) mcpserver.ToolHan } func (s *Server) runAcknowledgedEdit(ctx context.Context, req mcp.CallToolRequest, h mcpserver.ToolHandlerFunc) (result *mcp.CallToolResult, err error) { + if s.editAckConfigErr != nil { + return mcp.NewToolResultError(s.editAckConfigErr.Error()), nil + } + if policy, captured := ctx.Value(sourceAckPolicyKey{}).(sourceAckPolicy); !captured || policy.owner != s { + policy = s.sourceAckPolicy(ctx) + policy.freshness = takeRequestFreshness(&req) + if policy.freshness.err != nil { + return mcp.NewToolResultError(policy.freshness.err.Error()), nil + } + ctx = context.WithValue(ctx, sourceAckPolicyKey{}, policy) + } ctx, ack, owner := s.withEditAcknowledgement(ctx) if owner { defer func() { ack.finishReply(result, err) }() diff --git a/internal/mcp/tools_analysis.go b/internal/mcp/tools_analysis.go index bec7687a4..16a0ce0f0 100644 --- a/internal/mcp/tools_analysis.go +++ b/internal/mcp/tools_analysis.go @@ -521,7 +521,9 @@ func (s *Server) tryImpactAnalysisSnapshots() (*analysis.CommunityResult, *analy // handleEnhancedChangeImpact replaces the original explain_change_impact with risk tiering // and cross-community warnings. -func (s *Server) handleEnhancedChangeImpact(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (s *Server) handleEnhancedChangeImpact(ctx context.Context, req mcp.CallToolRequest) (out *mcp.CallToolResult, outErr error) { + ctx, waits := withImpactCoordinationWaits(ctx) + defer func() { out = attachImpactCoordinationWaits(out, waits) }() idsStr, err := req.RequireString("ids") if err != nil { return mcp.NewToolResultError("ids is required"), nil diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index 273ff3fb3..9e8d4b087 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -1022,11 +1022,14 @@ func (s *Server) handleGetSymbolSource(ctx context.Context, req mcp.CallToolRequ if guardErr := s.guardSymlinkWithinRepo(ctx, absPath); guardErr != nil { return mcp.NewToolResultError(guardErr.Error()), nil } - var err error - source, startLine, totalFileChars, err = s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, contextLines) + ns, err := s.readNodeSource(ctx, node, absPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("could not read source: %v", err)), nil } + resolved := *node + resolved.StartLine, resolved.EndLine = ns.startLine, ns.endLine + node = &resolved + source, startLine, totalFileChars, _ = extractLinesFromContent(ns.content, ns.startLine, ns.endLine, contextLines) } compressBodies := req.GetBool("compress_bodies", false) @@ -1353,7 +1356,9 @@ func (s *Server) handleBatchSymbols(ctx context.Context, req mcp.CallToolRequest // Source code (optional). if includeSource && node.StartLine > 0 && node.EndLine > 0 { if absPath, err := s.resolveNodePath(ctx, node); err == nil { - if source, fromLine, totalFileChars, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, contextLines); err == nil { + if ns, err := s.readNodeSource(ctx, node, absPath); err == nil { + source, fromLine, totalFileChars, _ := extractLinesFromContent(ns.content, ns.startLine, ns.endLine, contextLines) + entry["start_line"], entry["end_line"] = ns.startLine, ns.endLine entry["source"] = source entry["from_line"] = fromLine pending = append(pending, pendingSavings{ @@ -1719,7 +1724,8 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque // 1. Get the example source. if node.StartLine > 0 && node.EndLine > 0 { if absPath, err := s.resolveNodePath(ctx, node); err == nil { - if source, _, _, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, 0); err == nil { + if ns, err := s.readNodeSource(ctx, node, absPath); err == nil { + source, _, _, _ := extractLinesFromContent(ns.content, ns.startLine, ns.endLine, 0) result["example_source"] = elideSourceForPattern(source, maxSourceLines) } } @@ -1767,7 +1773,9 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque // Get the registration source (the caller function that wires this symbol). if cn.StartLine > 0 && cn.EndLine > 0 { if absPath, err := s.resolveNodePath(ctx, cn); err == nil { - if source, _, _, err := s.readLinesForCtx(ctx, absPath, cn.StartLine, cn.EndLine, 0); err == nil { + if ns, err := s.readNodeSource(ctx, cn, absPath); err == nil { + source, _, _, _ := extractLinesFromContent(ns.content, ns.startLine, ns.endLine, 0) + entry["start_line"] = ns.startLine entry["source"] = elideSourceForPattern(source, maxSourceLines) } } @@ -1797,7 +1805,9 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque // Get test source. if tn.StartLine > 0 && tn.EndLine > 0 { if absPath, err := s.resolveNodePath(ctx, tn); err == nil { - if source, _, _, err := s.readLinesForCtx(ctx, absPath, tn.StartLine, tn.EndLine, 0); err == nil { + if ns, err := s.readNodeSource(ctx, tn, absPath); err == nil { + source, _, _, _ := extractLinesFromContent(ns.content, ns.startLine, ns.endLine, 0) + entry["start_line"] = ns.startLine entry["source"] = elideSourceForPattern(source, maxSourceLines) } } @@ -1847,6 +1857,7 @@ func (s *Server) handleGetEditPlan(ctx context.Context, req mcp.CallToolRequest) ids[i] = strings.TrimSpace(ids[i]) } + gaps := s.laggingMutationReceipts(ctx) depth := req.GetInt("depth", 3) type editStep struct { @@ -1987,12 +1998,16 @@ func (s *Server) handleGetEditPlan(ctx context.Context, req mcp.CallToolRequest) } } - return s.respondJSONOrTOON(ctx, req, map[string]any{ + response := map[string]any{ "edit_order": editSteps, "test_after": testSteps, "total_files": len(steps), "summary": fmt.Sprintf("%d files to edit, %d test files to verify", len(editSteps), len(testSteps)), - }) + } + label := s.labelRelations(ctx, gaps, ids, nil) + label.apply(response) + res, err := s.respondJSONOrTOON(ctx, req, response) + return label.decorate(res), err } // extractPrefix returns the common prefix of a camelCase/PascalCase name. @@ -2301,7 +2316,9 @@ func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest (sym.Kind == graph.KindFunction || sym.Kind == graph.KindMethod) && sym.StartLine > 0 && sym.EndLine > 0 { if absPath, err := s.resolveNodePath(ctx, sym); err == nil { - if source, _, totalFileChars, err := s.readLinesForCtx(ctx, absPath, sym.StartLine, sym.EndLine, 0); err == nil { + if ns, err := s.readNodeSource(ctx, sym, absPath); err == nil { + source, _, totalFileChars, _ := extractLinesFromContent(ns.content, ns.startLine, ns.endLine, 0) + entry["start_line"] = ns.startLine if red, did := s.maybeRedactConfigLeaf(sym.Language, sym.FilePath, false, source); did { source = red } @@ -2741,6 +2758,7 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest dryRun := req.GetBool("dry_run", false) allowParseErrors := req.GetBool("allow_parse_errors", false) + gaps := s.laggingMutationReceipts(ctx) node := s.engineFor(ctx).GetSymbol(id) if node == nil { if recovery := s.unindexedRenameRecovery(ctx, id, newName, dryRun); recovery != nil { @@ -2903,27 +2921,80 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest } } + // Graph-backed edits in a pending file are provisional too: its current + // line can have a different binding even when the spelling still matches. + pendingFiles := make(map[string]bool, len(gaps)) + for _, gap := range gaps { + pendingFiles[s.repoRelative(gap.path)] = true + } + var verified, provisional []renameEdit + for _, edit := range edits { + if pendingFiles[edit.File] { + edit.Confidence = "provisional" + edit.Reason = "relations pending; verify this site manually" + provisional = append(provisional, edit) + } else { + verified = append(verified, edit) + } + } + for _, site := range s.provisionalSites(s.readerFor(ctx), gaps, node) { + key := fmt.Sprintf("%s:%d", site.File, site.Line) + if editSeen[key] { + continue + } + editSeen[key] = true + replacement, _ := rewrite(site.Text) + provisional = append(provisional, renameEdit{File: site.File, Line: site.Line, + OldText: site.Text, NewText: replacement, Confidence: "provisional", + Reason: "relations pending; verify this site manually"}) + } + // Only verified edits ever reach planRenameWrites and the commit loop. + edits = verified + reported := append(append([]renameEdit(nil), verified...), provisional...) + label := s.labelRelations(ctx, gaps, []string{id}, nil) + respond := func(payload map[string]any) (*mcp.CallToolResult, error) { + label.apply(payload) + res, err := s.respondJSONOrTOON(ctx, req, payload) + return label.decorate(res), err + } + // Collect affected files. fileSet := make(map[string]bool) for _, e := range edits { fileSet[e.File] = true } + reportedFiles := make(map[string]bool, len(fileSet)) + for _, edit := range reported { + reportedFiles[edit.File] = true + } resp := map[string]any{ "old_name": oldName, "new_name": newName, - "edits": edits, - "total_edits": len(edits), - "files_affected": len(fileSet), + "edits": reported, + "total_edits": len(reported), + "files_affected": len(reportedFiles), "dry_run": dryRun, } + if len(provisional) > 0 { + resp["provisional_site_count"] = len(provisional) + resp["manual_required"] = true + } + if !dryRun && pendingFiles[node.FilePath] { + resp["status"] = "pending_definition" + resp["written"] = false + return respond(resp) + } if len(edits) == 0 { // Nothing to write either way. Say so rather than reporting an // applied rename that touched no bytes. resp["status"] = "no_edits" + if len(provisional) > 0 { + resp["status"] = "provisional_only" + } resp["written"] = false - return s.respondJSONOrTOON(ctx, req, resp) + return respond(resp) } // Lock every affected path before verifying, so the check that each line @@ -2936,7 +3007,7 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest } absPaths = append(absPaths, abs) } - releaseMutation, lockErr := acquireMutationPaths(ctx, absPaths) + ctx, releaseMutation, lockErr := acquireMutationPaths(ctx, absPaths) if lockErr != nil { return mcp.NewToolResultError("rename cancelled while waiting for exclusive file access: " + lockErr.Error()), nil } @@ -2951,10 +3022,10 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest resp["status"] = "would_apply" resp["written"] = false resp["files"] = previewRenameWrites(writes) - return s.respondJSONOrTOON(ctx, req, resp) + return respond(resp) } - results := s.commitRenameWrites(ctx, writes) + results := s.commitRenameWrites(ctx, writes, releaseMutation) written := 0 failed := 0 for _, r := range results { @@ -2965,6 +3036,9 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest failed++ } resp["status"] = "applied" + if len(provisional) > 0 { + resp["status"] = "applied_with_provisional" + } resp["written"] = written > 0 resp["files"] = results resp["files_written"] = written @@ -2978,7 +3052,7 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest resp["status"] = "failed" } } - return s.respondJSONOrTOON(ctx, req, resp) + return respond(resp) } func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -3024,7 +3098,7 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) if err != nil { return mcp.NewToolResultError(err.Error()), nil } - releaseMutation, lockErr := acquireMutationPath(ctx, absPath) + ctx, releaseMutation, lockErr := acquireMutationPath(ctx, absPath) if lockErr != nil { return mcp.NewToolResultError("edit cancelled while waiting for exclusive file access: " + lockErr.Error()), nil } @@ -3034,7 +3108,7 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) // landed old_source no longer matches, so an un-replayed retry would be // refused rather than answered with the original result. fingerprint := mutationFingerprint("edit_symbol", id, absPath, oldSource, newSource) - if replay, conflict, matched := s.replayMutation(mutationID, fingerprint); matched { + if replay, conflict, matched := s.replayMutationAfterPathRelease(ctx, mutationID, fingerprint, releaseMutation); matched { if conflict != "" { return mcp.NewToolResultError(conflict), nil } @@ -3067,91 +3141,27 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) } } - fileStr := string(content) - - // Extract the symbol's source lines to verify old_source is within them. - lines := strings.Split(fileStr, "\n") - if node.StartLine > len(lines) || node.EndLine > len(lines) { - return mcp.NewToolResultError("symbol line range exceeds file length"), nil + start, end, locateErr := s.symbolLinesUnderLock(ctx, node, absPath, content) + if locateErr != nil { + return mcp.NewToolResultError(locateErr.Error()), nil } - - symbolSource := strings.Join(lines[node.StartLine-1:node.EndLine], "\n") - - if findEOLMatches(symbolSource, oldSource).count == 0 { - // Expand search to include preceding doc comments (agents often include - // them because get_symbol_source returns context_lines above the symbol). - expandedStart := node.StartLine - 1 - for expandedStart > 0 { - trimmed := strings.TrimSpace(lines[expandedStart-1]) - if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || - strings.HasPrefix(trimmed, "*") || trimmed == "" { - expandedStart-- - } else { - break - } - } - if expandedStart < node.StartLine-1 { - symbolSource = strings.Join(lines[expandedStart:node.EndLine], "\n") + resolved := *node + resolved.StartLine, resolved.EndLine = start, end + node = &resolved + fileStr := string(content) + edit, locateErr := locateSymbolEdit(fileStr, start, end, oldSource) + if locateErr != nil { + if errors.Is(locateErr, errEditFragmentRepeated) { + return mcp.NewToolResultError("old_source appears multiple times within the symbol. Provide a larger fragment to make it unique."), nil } - } - - srcMatches := findEOLMatches(symbolSource, oldSource) - if srcMatches.count == 0 { - return mcp.NewToolResultError(fmt.Sprintf( - "old_source not found within symbol %s (lines %d-%d). Use get_symbol_source to see the current code.", - id, node.StartLine, node.EndLine)), nil - } - - // Verify old_source is unique within the symbol. - if srcMatches.count > 1 { - return mcp.NewToolResultError( - "old_source appears multiple times within the symbol. Provide a larger fragment to make it unique."), nil - } - - // Apply the edit to the full file content. - // Find old_source within the symbol's line range only (not the whole file). - // Use the expanded start if doc comments were included. - effectiveStart := node.StartLine - if findEOLMatches(strings.Join(lines[node.StartLine-1:node.EndLine], "\n"), oldSource).count == 0 { - // Recalculate expanded start for offset computation. - expandedStart := node.StartLine - 1 - for expandedStart > 0 { - trimmed := strings.TrimSpace(lines[expandedStart-1]) - if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || - strings.HasPrefix(trimmed, "*") || trimmed == "" { - expandedStart-- - } else { - break - } + if errors.Is(locateErr, errEditFragmentNotFound) { + return mcp.NewToolResultError(fmt.Sprintf("old_source not found within symbol %s (lines %d-%d). Use get_symbol_source to see the current code.", id, start, end)), nil } - effectiveStart = expandedStart + 1 - } - - symbolStart := 0 - for i := 0; i < effectiveStart-1 && i < len(lines); i++ { - symbolStart += len(lines[i]) + 1 // +1 for newline + return mcp.NewToolResultError(locateErr.Error()), nil } - - symbolEnd := symbolStart + len(symbolSource) - if symbolEnd > len(fileStr) { - symbolEnd = len(fileStr) - } - - // Find old_source within the symbol region (EOL-tolerant: an - // LF-authored fragment still matches a CRLF region). Spans are byte - // offsets into the raw region, so the splice below always lands on the - // real on-disk bytes. - regionMatches := findEOLMatches(fileStr[symbolStart:symbolEnd], oldSource) - if len(regionMatches.spans) == 0 { - return mcp.NewToolResultError("old_source not found in symbol region"), nil - } - span := regionMatches.spans[0] - - // Build the new file content. - editStart := symbolStart + span.start - editEnd := symbolStart + span.end + editStart, editEnd := edit.start, edit.end effectiveNew := newSource - if regionMatches.normalized { + if edit.normalized { // Rewrite new_source's terminators to the matched region's own // style so the splice never introduces mixed line endings. effectiveNew = adaptToDominantEOL(newSource, fileStr[editStart:editEnd]) @@ -3177,7 +3187,7 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) "diff": unifiedDiff(node.FilePath, fileStr, newContent), "new_sha": gitBlobSHA(newContentBytes), } - if regionMatches.normalized { + if edit.normalized { preview["eol_normalized"] = true } return s.respondJSONOrTOON(ctx, req, preview) @@ -3189,6 +3199,7 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) perm = info.Mode().Perm() } commit, writeErr := s.commitFileMutation(ctx, "edit_symbol", mutationID, fingerprint, node.FilePath, absPath, newContentBytes, perm) + defer commit.finishResponseWait() if writeErr != nil { if errors.Is(writeErr, errMutationNotApplied) { return mcp.NewToolResultError(mutationNotAppliedMessage("edit", commit, writeErr)), nil @@ -3202,7 +3213,9 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) sess.recordModified(node.FilePath) sess.recordSymbol(id) - reindexOutcome := s.mutationReindexState(ctx, absPath) + admission := s.admitSourceMutations(ctx, absPath) + releaseMutation() + reindexOutcome := s.awaitSourceMutations(ctx, admission) commit.recordGraph(reindexOutcome) // Count lines changed. @@ -3218,7 +3231,7 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) "status": "applied", "new_sha": gitBlobSHA(newContentBytes), } - if regionMatches.normalized { + if edit.normalized { resp["eol_normalized"] = true } if reindexOutcome.Err != nil { diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index d6216f81e..b4947d818 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -1549,6 +1549,7 @@ func (s *Server) handleReindexRepository(ctx context.Context, req mcp.CallToolRe // the cutoff declines some safe resolutions, which is the right way // to be wrong here. eligible map[string]struct{} + repair func() ) // Multi-repo mode: route through the per-repo indexer so nodes keep @@ -1580,6 +1581,7 @@ func (s *Server) handleReindexRepository(ctx context.Context, req mcp.CallToolRe return mcp.NewToolResultError(scopeErr.Error()), nil } eligible = s.failedReceiptsBefore(paths, reindexRoot) + repair = s.captureWatcherTierRepair(paths, reindexRoot) result, err = s.multiIndexer.IncrementalReindexRepo(prefix, paths) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -1602,6 +1604,7 @@ func (s *Server) handleReindexRepository(ctx context.Context, req mcp.CallToolRe } reindexRoot = root eligible = s.failedReceiptsBefore(paths, reindexRoot) + repair = s.captureWatcherTierRepair(paths, reindexRoot) result, err = s.indexer.IncrementalReindexPaths(root, paths) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -1622,6 +1625,9 @@ func (s *Server) handleReindexRepository(ctx context.Context, req mcp.CallToolRe // prevent — so anything wider is left alone. if resolved, ok := reindexedReceiptPath(paths, reindexRoot, result); ok { s.resolveReindexedPathReceipts(resolved, eligible) + if repair != nil { + repair() + } } s.startBackgroundAnalysis("reindex") diff --git a/internal/mcp/tools_core_reindex_test.go b/internal/mcp/tools_core_reindex_test.go index 168d5cc6a..3445f9cd3 100644 --- a/internal/mcp/tools_core_reindex_test.go +++ b/internal/mcp/tools_core_reindex_test.go @@ -74,6 +74,10 @@ func TestHandleReindexRepository_ResolvesFailedReceiptForTheReparsedPath(t *test _, err := idx.Index(dir) require.NoError(t, err) srv := NewServer(query.NewEngine(g), g, idx, nil, zap.NewNop(), nil) + watcher := &reindexTierFixture{watchedTierFixture: watchedTierFixture{ + paths: map[string]indexer.MutationTierState{target: {Generation: 4, Failed: true}}, + }} + srv.SetWatcher(watcher) // A failed ingest for that exact path, plus one for a file the pass will // not touch: only the re-parsed path may be resolved. @@ -104,6 +108,18 @@ func TestHandleReindexRepository_ResolvesFailedReceiptForTheReparsedPath(t *test if outcome := untouched.outcome(false); outcome.Err == nil { t.Fatal("reindex resolved a receipt for a path it never re-parsed") } + require.Equal(t, target, watcher.captured, "the recovery frontier must be captured before the pass") + require.Empty(t, watcher.TierPaths(), "watcher failure evidence must be repaired alongside the MCP receipt") +} + +type reindexTierFixture struct { + watchedTierFixture + captured string +} + +func (w *reindexTierFixture) CaptureTierRepair(path string) func() { + w.captured = path + return func() { delete(w.paths, path) } } func TestHandleReindexRepository_WholeRepoSingleMode(t *testing.T) { diff --git a/internal/mcp/tools_enhancements.go b/internal/mcp/tools_enhancements.go index 5132d62ee..1a711569b 100644 --- a/internal/mcp/tools_enhancements.go +++ b/internal/mcp/tools_enhancements.go @@ -1,6 +1,7 @@ package mcp import ( + "cmp" "context" "encoding/json" "errors" @@ -12,6 +13,7 @@ import ( "slices" "sort" "strings" + "sync" "time" "github.com/mark3labs/mcp-go/mcp" @@ -26,6 +28,7 @@ import ( "github.com/zzet/gortex/internal/graphpath" "github.com/zzet/gortex/internal/graphview" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" "github.com/zzet/gortex/internal/persistence" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/semantic" @@ -425,7 +428,13 @@ func (s *Server) handleVerifyChange(ctx context.Context, req mcp.CallToolRequest return mcp.NewToolResultError("changes array is empty"), nil } + gaps := s.laggingMutationReceipts(ctx) result := analysis.VerifyChanges(s.readerFor(ctx), s.engineFor(ctx), changes) + ids := make([]string, 0, len(changes)) + for _, change := range changes { + ids = append(ids, change.SymbolID) + } + label := s.labelRelations(ctx, gaps, ids, nil) if isCompact(req) { var b strings.Builder @@ -453,10 +462,11 @@ func (s *Server) handleVerifyChange(ctx context.Context, req mcp.CallToolRequest if result.Clean { fmt.Fprintf(&b, "clean: checked %d callers, %d implementors\n", result.CheckedCallers, result.CheckedImpls) } - return mcp.NewToolResultText(b.String()), nil + return label.decorate(mcp.NewToolResultText(b.String())), nil } - return s.respondJSONOrTOON(ctx, req, result) + res, err := s.respondJSONOrTOON(ctx, req, label.labelled(result)) + return label.decorate(res), err } // --------------------------------------------------------------------------- @@ -2574,6 +2584,7 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest opts.SkipCrossRepoNodes = true } + gaps := s.laggingMutationReceipts(ctx) reader := s.readerFor(ctx) opts.RepoPrefixes = s.deadCodeRepoPrefixes(ctx) if scope, active := s.scopeOptionsWithRepoNarrow(ctx, repoAllowFromContext(ctx)); active { @@ -2609,6 +2620,22 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest entries = kept } + // A pending definition or an unindexed caller cannot support a dead-code + // verdict. Keep those candidates separate in every output mode. + var label relationFreshness + var pendingUnknown []analysis.DeadCodeEntry + dead := make([]analysis.DeadCodeEntry, 0, len(entries)) + for _, entry := range entries { + candidate := s.labelRelations(ctx, gaps, []string{entry.ID}, nil) + if candidate.state != "" { + pendingUnknown = append(pendingUnknown, entry) + label = label.merge(candidate) + } else { + dead = append(dead, entry) + } + } + entries = dead + // Cap response size — large repos surface thousands of dead-code // candidates and the default JSON encoding spills past the MCP // per-response token cap. Callers that need the full list can @@ -2617,12 +2644,15 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest if v, ok := args["limit"].(float64); ok && v > 0 { limit = int(v) } - totalEntries := len(entries) - deadTruncated := false - if len(entries) > limit { - entries = entries[:limit] - deadTruncated = true + totalEntries, totalUnknown := len(entries), len(pendingUnknown) + if len(pendingUnknown) > limit { + pendingUnknown = pendingUnknown[:limit] + } + remaining := limit - len(pendingUnknown) + if len(entries) > remaining { + entries = entries[:remaining] } + deadTruncated := len(entries) < totalEntries || len(pendingUnknown) < totalUnknown variablesNote := buildDeadCodeNote(opts) @@ -2637,7 +2667,23 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest Line: e.Line, }) } - return s.gcxResponseWithBudget(req)(encodeAnalyze("dead_code", items)) + data, err := encodeAnalyze("dead_code", items) + if err != nil { + return nil, err + } + if len(pendingUnknown) > 0 { + unknown := make([]deadCodeItem, 0, len(pendingUnknown)) + for _, e := range pendingUnknown { + unknown = append(unknown, deadCodeItem{ID: e.ID, Kind: e.Kind, Name: e.Name, Path: e.FilePath, Line: e.Line}) + } + section, err := encodeAnalyze("pending_unknown", unknown) + if err != nil { + return nil, err + } + data = append(data, section...) + } + res, err := s.gcxResponseWithBudget(req)(data, nil) + return label.decorate(res), err } if isCompact(req) { @@ -2645,13 +2691,16 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest for _, e := range entries { fmt.Fprintf(&b, "%s %s %s:%d\n", e.Kind, e.ID, e.FilePath, e.Line) } - if len(entries) == 0 { + for _, e := range pendingUnknown { + fmt.Fprintf(&b, "pending_unknown %s %s %s:%d\n", e.Kind, e.ID, e.FilePath, e.Line) + } + if len(entries) == 0 && len(pendingUnknown) == 0 { b.WriteString("no dead code found\n") } if variablesNote != "" { fmt.Fprintf(&b, "\nnote: %s\n", variablesNote) } - return mcp.NewToolResultText(b.String()), nil + return label.decorate(mcp.NewToolResultText(b.String())), nil } result := map[string]any{ @@ -2659,13 +2708,19 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest "total": totalEntries, "truncated": deadTruncated, } + if totalUnknown > 0 { + result["pending_unknown"] = pendingUnknown + result["pending_unknown_count"] = totalUnknown + } if deadTruncated { result["limit"] = limit } if variablesNote != "" { result["note"] = variablesNote } - return s.respondJSONOrTOON(ctx, req, result) + label.apply(result) + res, err := s.respondJSONOrTOON(ctx, req, result) + return label.decorate(res), err } // buildDeadCodeNote summarises which low-signal kinds the analyzer @@ -2840,7 +2895,7 @@ type scaffoldReader struct { ctx context.Context } -func (r scaffoldReader) Graph() graph.Store { return r.s.graph } +func (r scaffoldReader) Graph() graph.Reader { return r.s.readerFor(r.ctx) } func (r scaffoldReader) ResolveFilePath(graphPath string) string { abs, err := r.s.resolveGraphPath(r.ctx, graphPath) if err != nil { @@ -2849,6 +2904,31 @@ func (r scaffoldReader) ResolveFilePath(graphPath string) string { return abs } +func (r scaffoldReader) ScaffoldNodeSource(node *graph.Node) (string, int, int, error) { + path := r.ResolveFilePath(node.FilePath) + if path == "" { + return "", 0, 0, fmt.Errorf("could not resolve %s", node.FilePath) + } + raw, err := os.ReadFile(path) + if err != nil { + return "", 0, 0, err + } + var start, end int + if mutationPathAdmitted(r.ctx, pathkey.MutationKey(path)) { + start, end, err = r.s.symbolLinesUnderLock(r.ctx, node, path, raw) + } else { + start, end, err = r.s.nodeLinesIn(r.ctx, node, raw) + } + if err != nil { + return "", 0, 0, err + } + lines := strings.Split(string(indexer.DecodeUTF16Source(raw)), "\n") + if start < 1 || end < start || end > len(lines) { + return "", 0, 0, fmt.Errorf("invalid current source range for %s", node.ID) + } + return strings.Join(lines[start-1:end], "\n"), start, end, nil +} + func (s *Server) handleScaffold(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { exampleID, err := req.RequireString("id") if err != nil { @@ -2865,7 +2945,7 @@ func (s *Server) handleScaffold(ctx context.Context, req mcp.CallToolRequest) (* dryRun = v } - result, err := analysis.GenerateScaffold(s.engine, scaffoldReader{s: s, ctx: ctx}, exampleID, newName) + result, err := analysis.GenerateScaffold(s.engineFor(ctx), scaffoldReader{s: s, ctx: ctx}, exampleID, newName) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -2877,44 +2957,71 @@ func (s *Server) handleScaffold(ctx context.Context, req mcp.CallToolRequest) (* } if !dryRun && s.indexer != nil { - // Apply edits by writing files. Every write funnels through - // commitFileMutation — the UTF-16 refusal (#846), the cancellation - // gate, and the mutation receipt live there. A direct os.WriteFile - // used to splice UTF-8 into NUL-interleaved bytes and corrupt the - // file from the insertion point on. + paths := make([]string, 0, len(result.Edits)) for _, edit := range result.Edits { - absPath := edit.FilePath - if root := s.indexer.RootPath(); root != "" { - absPath = filepath.Join(root, edit.FilePath) + abs, err := s.resolveGraphPath(ctx, edit.FilePath) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } - content, readErr := os.ReadFile(absPath) + paths = append(paths, abs) + } + ctx, release, err := acquireMutationPaths(ctx, paths) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + defer release() + // Rebuild under the complete source frontier: a queued edit must not + // resurrect an example removed while it waited for a source lock. + current, err := analysis.GenerateScaffold(s.engineFor(ctx), scaffoldReader{s: s, ctx: ctx}, exampleID, newName) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + grouped := make(map[string][]analysis.ScaffoldEdit) + for _, edit := range current.Edits { + path, err := s.resolveGraphPath(ctx, edit.FilePath) + if err != nil || !mutationPathAdmitted(ctx, pathkey.MutationKey(path)) { + return mcp.NewToolResultError("scaffold source paths changed while waiting; rebuild the preview"), nil + } + grouped[path] = append(grouped[path], edit) + } + var writes []committedSourceMutation + finish := sync.OnceFunc(func() { s.completeCommittedSourceMutations(ctx, writes, release) }) + defer finish() + ordered := slices.Sorted(maps.Keys(grouped)) + for _, path := range ordered { + edits := grouped[path] + content, readErr := os.ReadFile(path) if readErr != nil { - return mcp.NewToolResultError(fmt.Sprintf("could not read %s: %v", edit.FilePath, readErr)), nil + return mcp.NewToolResultError(fmt.Sprintf("could not read %s: %v", edits[0].FilePath, readErr)), nil } + // Apply all positions against one source version, from the bottom + // up, then parse and commit just one final candidate for this file. + slices.SortStableFunc(edits, func(a, b analysis.ScaffoldEdit) int { return cmp.Compare(b.InsertionLine, a.InsertionLine) }) lines := strings.Split(string(content), "\n") - insertIdx := max(edit.InsertionLine-1, 0) - insertIdx = min(insertIdx, len(lines)) - newLines := make([]string, 0, len(lines)+strings.Count(edit.Code, "\n")+2) - newLines = append(newLines, lines[:insertIdx]...) - newLines = append(newLines, "") - newLines = append(newLines, edit.Code) - newLines = append(newLines, lines[insertIdx:]...) - // Keep the file's existing mode: the atomic commit applies perm - // to the replacement, so a fixed 0o644 would strip a script's - // executable bit. + for _, edit := range edits { + at := min(max(edit.InsertionLine-1, 0), len(lines)) + lines = append(slices.Clone(lines[:at]), append([]string{"", edit.Code}, lines[at:]...)...) + } + after := []byte(strings.Join(lines, "\n")) + projection, gate := s.prepareAcknowledgedProjection(ctx, edits[0].FilePath, path, content, after) + if gate.Blocked { + return mcp.NewToolResultError("scaffold refused: candidate introduces syntax errors"), nil + } perm := os.FileMode(0o644) - if info, err := os.Stat(absPath); err == nil { + if info, statErr := os.Stat(path); statErr == nil { perm = info.Mode().Perm() } - commit, writeErr := s.commitFileMutation(ctx, "scaffold", "", "", edit.FilePath, absPath, - []byte(strings.Join(newLines, "\n")), perm) + commit, writeErr := s.commitFileMutation(ctx, "scaffold", "", "", edits[0].FilePath, path, + after, perm, sourceMutationOperation{kind: sourceWrite, projection: projection}) + if commit != nil && commit.snapshot().DiskStatus == mutationDiskCommitted { + writes = append(writes, captureCommittedSourceMutation(ctx, path, commit)) + } if writeErr != nil { - if errors.Is(writeErr, errMutationNotApplied) { - return mcp.NewToolResultError(mutationNotAppliedMessage("scaffold", commit, writeErr)), nil - } - return mcp.NewToolResultError(fmt.Sprintf("could not write %s: %v", edit.FilePath, writeErr)), nil + return mcp.NewToolResultError(mutationNotAppliedMessage("scaffold", commit, writeErr)), nil } } + finish() + resp["edits"], resp["notes"] = current.Edits, current.Notes resp["applied"] = true } @@ -4402,244 +4509,6 @@ func (s *Server) handleBatchEdit(ctx context.Context, req mcp.CallToolRequest) ( return s.handleAtomicBatchEdit(ctx, req) } -// applyBatchSymbolEdit applies one edit_symbol operation: it locates the -// symbol's source range, replaces old_source with new_source inside it, writes -// the file, and re-indexes. Semantics match the legacy single-op batch_edit. -func (s *Server) applyBatchSymbolEdit(ctx context.Context, edit batchEditItem, write bool) batchEditResult { - res := batchEditResult{Op: "edit_symbol", SymbolID: edit.SymbolID} - if edit.OldSource == edit.NewSource { - res.Status, res.Error = "failed", "old_source and new_source are identical" - return res - } - node := s.engineFor(ctx).GetSymbol(edit.SymbolID) - if node == nil { - res.Status, res.Error = "failed", "symbol not found: "+edit.SymbolID - return res - } - res.FilePath = node.FilePath - if node.StartLine == 0 || node.EndLine == 0 { - res.Status, res.Error = "failed", "symbol has no line range" - return res - } - absPath, resolveErr := s.resolveNodePath(ctx, node) - if resolveErr != nil { - res.Status, res.Error = "failed", resolveErr.Error() - return res - } - if write { - releaseMutation, lockErr := acquireMutationPath(ctx, absPath) - if lockErr != nil { - res.Status, res.Error = "failed", "edit cancelled while waiting for exclusive file access: "+lockErr.Error() - return res - } - defer releaseMutation() - } - content, readErr := os.ReadFile(absPath) - if readErr != nil { - res.Status, res.Error = "failed", fmt.Sprintf("could not read file: %v", readErr) - return res - } - // Same UTF-16 refusal as edit_symbol: the match below would fail - // with a misleading "not found" against NUL-interleaved bytes (#846). - if indexer.LooksUTF16Source(content) { - res.Status, res.Error = "failed", refuseUTF16Edit("batch edit_symbol", node.FilePath).Error() - return res - } - fileStr := string(content) - lines := strings.Split(fileStr, "\n") - - // Prefer the indexed symbol range, including preceding documentation. If a - // prior edit in this batch shifted line numbers while watcher reindex is - // pending, fall back only when old_source is unique in the current file. - regionMatches := findEOLMatches(fileStr, edit.OldSource) - symbolStart := 0 - rangeMatched := false - if node.StartLine <= len(lines) && node.EndLine <= len(lines) { - symbolSource := strings.Join(lines[node.StartLine-1:node.EndLine], "\n") - effectiveStart := node.StartLine - if findEOLMatches(symbolSource, edit.OldSource).count == 0 { - expandedStart := node.StartLine - 1 - for expandedStart > 0 { - trimmed := strings.TrimSpace(lines[expandedStart-1]) - if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || - strings.HasPrefix(trimmed, "*") || trimmed == "" { - expandedStart-- - } else { - break - } - } - if expandedStart < node.StartLine-1 { - expanded := strings.Join(lines[expandedStart:node.EndLine], "\n") - if findEOLMatches(expanded, edit.OldSource).count > 0 { - symbolSource = expanded - effectiveStart = expandedStart + 1 - } - } - } - for i := 0; i < effectiveStart-1 && i < len(lines); i++ { - symbolStart += len(lines[i]) + 1 - } - symbolEnd := min(symbolStart+len(symbolSource), len(fileStr)) - candidate := findEOLMatches(fileStr[symbolStart:symbolEnd], edit.OldSource) - if candidate.count == 1 { - regionMatches = candidate - rangeMatched = true - } - } - if !rangeMatched { - symbolStart = 0 - switch regionMatches.count { - case 0: - res.Status, res.Error = "failed", "old_source not found within symbol or current file" - return res - case 1: - // Safe stale-range fallback. - default: - res.Status, res.Error = "failed", "symbol range is stale and old_source is not unique in the current file" - return res - } - } - span := regionMatches.spans[0] - editStart := symbolStart + span.start - editEnd := symbolStart + span.end - effectiveNew := edit.NewSource - if regionMatches.normalized { - effectiveNew = adaptToDominantEOL(edit.NewSource, fileStr[editStart:editEnd]) - res.EOLNormalized = true - } - newContent := fileStr[:editStart] + effectiveNew + fileStr[editEnd:] - if newContent == fileStr { - res.Status, res.Error = "failed", "old_source and new_source are identical after line-ending normalization" - return res - } - if !write { - res.Status = "validated" - return res - } - perm := os.FileMode(0o644) - if info, statErr := os.Stat(absPath); statErr == nil { - perm = info.Mode().Perm() - } - commit, writeErr := s.commitFileMutation(ctx, "batch_edit", "", "", node.FilePath, absPath, []byte(newContent), perm) - if writeErr != nil { - res.Status, res.Error = "failed", fmt.Sprintf("could not write file: %v", writeErr) - return res - } - sess := s.sessionFor(ctx) - sess.recordModified(node.FilePath) - sess.recordSymbol(edit.SymbolID) - reindexOutcome := s.mutationReindexState(ctx, absPath) - commit.recordGraph(reindexOutcome) - res.Reindexed, res.ReindexPending = reindexOutcome.Reindexed, reindexOutcome.Pending - res.ReindexReceipt = reindexOutcome.Receipt - res.ReindexGeneration = reindexOutcome.Generation - res.ReindexAppliedGeneration = reindexOutcome.AppliedGeneration - if reindexOutcome.Err != nil { - res.ReindexError = reindexOutcome.Err.Error() - } - res.Status = "applied" - return res -} - -// applyBatchFileEdit applies one edit_file operation: it replaces old_string -// with new_string in the file at path, mirroring edit_file's uniqueness and -// replace_all semantics, then re-indexes. -func (s *Server) applyBatchFileEdit(ctx context.Context, edit batchEditItem, write bool) batchEditResult { - res := batchEditResult{Op: "edit_file", FilePath: edit.Path} - if edit.Path == "" { - res.Status, res.Error = "failed", "edit_file op requires path" - return res - } - if edit.OldString == edit.NewString { - res.Status, res.Error = "failed", "old_string and new_string are identical" - return res - } - absPath, relPath, resolveErr := s.resolveFilePath(ctx, edit.Path) - if resolveErr != nil { - res.Status, res.Error = "failed", resolveErr.Error() - return res - } - res.FilePath = relPath - if write { - releaseMutation, lockErr := acquireMutationPath(ctx, absPath) - if lockErr != nil { - res.Status, res.Error = "failed", "edit cancelled while waiting for exclusive file access: "+lockErr.Error() - return res - } - defer releaseMutation() - } - content, readErr := os.ReadFile(absPath) - if readErr != nil { - res.Status, res.Error = "failed", fmt.Sprintf("could not read file: %v", readErr) - return res - } - // Same UTF-16 refusal as edit_file: the match below would fail with a - // misleading "not found" against NUL-interleaved bytes (#846). - if indexer.LooksUTF16Source(content) { - res.Status, res.Error = "failed", refuseUTF16Edit("batch edit_file", relPath).Error() - return res - } - fileStr := string(content) - matches := findEOLMatches(fileStr, edit.OldString) - count := matches.count - if count == 0 { - res.Status, res.Error = "failed", "old_string not found in file" - return res - } - if count > 1 && !edit.ReplaceAll { - res.Status, res.Error = "failed", fmt.Sprintf( - "old_string matches %d locations%s. Provide a larger fragment for uniqueness or set replace_all=true.", - count, matchSpansHint(fileStr, matches.spans)) - return res - } - var newContent string - switch { - case matches.normalized: - // The CRLF<->LF fallback matched: splice the real byte spans and - // write new_string with each region's own line terminators so the - // edit never introduces mixed endings. - limit := 1 - if edit.ReplaceAll { - limit = -1 - } - newContent = spliceSpansEOL(fileStr, matches.spans, edit.NewString, limit) - if newContent == fileStr { - res.Status, res.Error = "failed", "old_string and new_string are identical after line-ending normalization" - return res - } - res.EOLNormalized = true - case edit.ReplaceAll: - newContent = strings.ReplaceAll(fileStr, edit.OldString, edit.NewString) - default: - newContent = strings.Replace(fileStr, edit.OldString, edit.NewString, 1) - } - if !write { - res.Status = "validated" - return res - } - perm := os.FileMode(0o644) - if info, statErr := os.Stat(absPath); statErr == nil { - perm = info.Mode().Perm() - } - commit, writeErr := s.commitFileMutation(ctx, "batch_edit", "", "", relPath, absPath, []byte(newContent), perm) - if writeErr != nil { - res.Status, res.Error = "failed", fmt.Sprintf("could not write file: %v", writeErr) - return res - } - s.sessionFor(ctx).recordModified(relPath) - reindexOutcome := s.mutationReindexState(ctx, absPath) - commit.recordGraph(reindexOutcome) - res.Reindexed, res.ReindexPending = reindexOutcome.Reindexed, reindexOutcome.Pending - res.ReindexReceipt = reindexOutcome.Receipt - res.ReindexGeneration = reindexOutcome.Generation - res.ReindexAppliedGeneration = reindexOutcome.AppliedGeneration - if reindexOutcome.Err != nil { - res.ReindexError = reindexOutcome.Err.Error() - } - res.Status = "applied" - return res -} - // --------------------------------------------------------------------------- // handleContracts — unified dispatcher for contracts (replaces 2 tools) // --------------------------------------------------------------------------- diff --git a/internal/mcp/tools_fileops.go b/internal/mcp/tools_fileops.go index 77b52c8ea..1e114af45 100644 --- a/internal/mcp/tools_fileops.go +++ b/internal/mcp/tools_fileops.go @@ -925,7 +925,7 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* if resolveErr != nil { return mcp.NewToolResultError(resolveErr.Error()), nil } - releaseMutation, lockErr := acquireMutationPath(ctx, absPath) + ctx, releaseMutation, lockErr := acquireMutationPath(ctx, absPath) if lockErr != nil { return mcp.NewToolResultError("edit cancelled while waiting for exclusive file access: " + lockErr.Error()), nil } @@ -935,7 +935,7 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* // old_string, so an un-replayed retry would fail the match check with a // confusing "not found" instead of returning the original result. fingerprint := mutationFingerprint("edit_file", absPath, oldString, newString, strconv.FormatBool(replaceAll)) - if replay, conflict, matched := s.replayMutation(mutationID, fingerprint); matched { + if replay, conflict, matched := s.replayMutationAfterPathRelease(ctx, mutationID, fingerprint, releaseMutation); matched { if conflict != "" { return mcp.NewToolResultError(conflict), nil } @@ -1016,8 +1016,9 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* allowParseErrors := req.GetBool("allow_parse_errors", false) var gate parseGateResult + var prepared *preparedFileProjection if parseGateEnabled() { - gate = s.checkAcknowledgedParseGate(ctx, relPath, content, newContentBytes) + prepared, gate = s.prepareAcknowledgedProjection(ctx, relPath, absPath, content, newContentBytes) if gate.Blocked && !allowParseErrors && !dryRun { return mcp.NewToolResultError(parseGateError(relPath, gate)), nil } @@ -1051,7 +1052,8 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* if info, err := os.Stat(absPath); err == nil { perm = info.Mode().Perm() } - commit, writeErr := s.commitFileMutation(ctx, "edit_file", mutationID, fingerprint, relPath, absPath, newContentBytes, perm) + commit, writeErr := s.commitFileMutation(ctx, "edit_file", mutationID, fingerprint, relPath, absPath, newContentBytes, perm, sourceMutationOperation{kind: sourceWrite, projection: prepared}) + defer commit.finishResponseWait() if writeErr != nil { if errors.Is(writeErr, errMutationNotApplied) { return mcp.NewToolResultError(mutationNotAppliedMessage("edit", commit, writeErr)), nil @@ -1065,7 +1067,9 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* sess := s.sessionFor(ctx) sess.recordModified(relPath) - reindexOutcome := s.mutationReindexState(ctx, absPath) + admission := s.admitSourceMutations(ctx, absPath) + releaseMutation() + reindexOutcome := s.awaitSourceMutations(ctx, admission) commit.recordGraph(reindexOutcome) resp := map[string]any{ @@ -1119,7 +1123,7 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( if resolveErr != nil { return mcp.NewToolResultError(resolveErr.Error()), nil } - releaseMutation, lockErr := acquireMutationPath(ctx, absPath) + ctx, releaseMutation, lockErr := acquireMutationPath(ctx, absPath) if lockErr != nil { return mcp.NewToolResultError("write cancelled while waiting for exclusive file access: " + lockErr.Error()), nil } @@ -1129,7 +1133,7 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( // call waits for it and then sees a terminal record, rather than both // deciding independently that they are the first. fingerprint := mutationFingerprint("write_file", absPath, content) - if replay, conflict, matched := s.replayMutation(mutationID, fingerprint); matched { + if replay, conflict, matched := s.replayMutationAfterPathRelease(ctx, mutationID, fingerprint, releaseMutation); matched { if conflict != "" { return mcp.NewToolResultError(conflict), nil } @@ -1191,8 +1195,9 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( allowParseErrors := req.GetBool("allow_parse_errors", false) var gate parseGateResult + var prepared *preparedFileProjection if parseGateEnabled() { - gate = s.checkAcknowledgedParseGate(ctx, relPath, priorContent, contentBytes) + prepared, gate = s.prepareAcknowledgedProjection(ctx, relPath, absPath, priorContent, contentBytes) if gate.Blocked && !allowParseErrors && !dryRun { return mcp.NewToolResultError(parseGateError(relPath, gate)), nil } @@ -1224,7 +1229,8 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( return s.respondJSONOrTOON(ctx, req, preview) } - commit, writeErr := s.commitFileMutation(ctx, "write_file", mutationID, fingerprint, relPath, absPath, contentBytes, perm) + commit, writeErr := s.commitFileMutation(ctx, "write_file", mutationID, fingerprint, relPath, absPath, contentBytes, perm, sourceMutationOperation{kind: sourceWrite, projection: prepared}) + defer commit.finishResponseWait() if writeErr != nil { if errors.Is(writeErr, errMutationNotApplied) { return mcp.NewToolResultError(mutationNotAppliedMessage("write", commit, writeErr)), nil @@ -1238,7 +1244,9 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( sess := s.sessionFor(ctx) sess.recordModified(relPath) - reindexOutcome := s.mutationReindexState(ctx, absPath) + admission := s.admitSourceMutations(ctx, absPath) + releaseMutation() + reindexOutcome := s.awaitSourceMutations(ctx, admission) commit.recordGraph(reindexOutcome) resp := map[string]any{ diff --git a/internal/mcp/tools_move_inline.go b/internal/mcp/tools_move_inline.go index c08e7ffce..b9bc1169d 100644 --- a/internal/mcp/tools_move_inline.go +++ b/internal/mcp/tools_move_inline.go @@ -17,7 +17,6 @@ import ( "github.com/mark3labs/mcp-go/mcp" "golang.org/x/tools/go/ast/astutil" - "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/graph" ) @@ -378,42 +377,77 @@ func (s *Server) handleMoveSymbol(ctx context.Context, req mcp.CallToolRequest) return s.respondJSONOrTOON(ctx, req, resp) } + plans := []plannedSource{{path: srcAbs, before: srcContent, existed: true}, {path: tgtAbs, before: tgtContent, existed: tgtExists}} + for _, edit := range callerEdits { + plans = append(plans, plannedSource{path: edit.absPath, before: edit.origContent, existed: true}) + } + ctx, release, lockErr := lockPlannedSources(ctx, plans) + if lockErr != nil { + return mcp.NewToolResultError(lockErr.Error()), nil + } + defer release() + // Preserve the move's existing terminal write sequence after the complete + // CAS/cancellation boundary; do not strand its target half on cancellation. + ctx = context.WithoutCancel(ctx) + physical := make(map[string]*mutationCommitRecord, len(plans)) measurement := s.beginMoveObservation(ctx, id, resp["target_id"].(string), srcAbs, tgtAbs, srcRel, tgtRelOut, samePackage && len(callerEdits) == 0) defer measurement.finishWrites(false) + var targets []sourceMutationTarget + completed := false + finish := func() { + if completed { + return + } + completed = true + // Writes remain target-first. Preserve the embedded adapter's + // historical source-before-target reconcile order after unlocking. + if len(targets) >= 2 && targets[0].path == tgtAbs && targets[1].path == srcAbs { + targets[0], targets[1] = targets[1], targets[0] + } + admissions := s.admitSourceMutationFrontier(ctx, targets) + for i, target := range targets { + measurement.observeAdmission(target.path, &admissions[i]) + } + release() + for i, outcome := range s.awaitSourceMutationFrontier(ctx, admissions) { + physical[targets[i].path].recordGraph(outcome) + } + } + defer finish() // Admit an already-committed subset even after a later I/O fault. + write := func(path string, content []byte) error { + record, err := measurement.write(ctx, s, path, content) + if record != nil { + physical[path] = record + if err == nil { + targets = append(targets, captureSourceMutationTarget(ctx, path)) + } + } + return err + } // Apply writes atomically. Order: target first (so file exists for any // caller that triggers a follow-up open), then source, then callers. - if err := measurement.write(editAcknowledgementFrom(ctx, s), tgtAbs, newTgtContent); err != nil { + if err := write(tgtAbs, newTgtContent); err != nil { return mcp.NewToolResultError(err.Error()), nil } observeEditSourceReplacement(ctx, tgtContent, newTgtContent, tgtExists, true) - if err := measurement.write(editAcknowledgementFrom(ctx, s), srcAbs, newSrcContent); err != nil { + if err := write(srcAbs, newSrcContent); err != nil { return mcp.NewToolResultError(err.Error()), nil } observeEditSourceReplacement(ctx, srcContent, newSrcContent, true, true) for _, ce := range callerEdits { - if err := measurement.write(editAcknowledgementFrom(ctx, s), ce.absPath, ce.newContent); err != nil { + if err := write(ce.absPath, ce.newContent); err != nil { return mcp.NewToolResultError(err.Error()), nil } observeEditSourceReplacement(ctx, ce.origContent, ce.newContent, true, true) } measurement.finishWrites(true) observeEditSourceComplete(ctx) + finish() - // Re-index every touched file so the graph picks up the new location. sess := s.sessionFor(ctx) for _, t := range touched { sess.recordModified(t.Path) } - if measurement != nil { - _ = measurement.reindex(ctx, srcAbs) - _ = measurement.reindex(ctx, tgtAbs) - } else { - _ = s.reindexFile(srcAbs) - _ = s.reindexFile(tgtAbs) - } - for _, ce := range callerEdits { - _ = s.reindexFile(ce.absPath) - } if measurement != nil { resp["move_observation_id"] = measurement.id @@ -464,25 +498,15 @@ func buildMovedSymbolID(s *Server, tgtAbs, name string) string { return rel + "::" + name } -func writeMoveFile(absPath string, content []byte) error { - return writeMoveFileObserved(nil, absPath, content) -} - -func writeMoveFileObserved(a *editAcknowledgement, absPath string, content []byte) error { +func (s *Server) writeMoveFile(ctx context.Context, tool, absPath string, content []byte) (*mutationCommitRecord, error) { perm := os.FileMode(0o644) if info, err := os.Stat(absPath); err == nil { perm = info.Mode().Perm() } if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { - return fmt.Errorf("could not create parent dir for %s: %w", absPath, err) + return nil, fmt.Errorf("could not create parent dir for %s: %w", absPath, err) } - lap := beginEditAcknowledgementLap(a, editAckDiskWrite) - err := agents.AtomicWriteFile(absPath, content, perm) - lap.finish() - if err != nil { - return fmt.Errorf("could not write %s: %w", absPath, err) - } - return nil + return s.commitFileMutation(ctx, tool, "", "", s.repoRelative(absPath), absPath, content, perm) } // extractGoTopLevelDecl finds the top-level declaration named `name` inside @@ -1243,18 +1267,41 @@ func (s *Server) handleInlineSymbol(ctx context.Context, req mcp.CallToolRequest }) } - // Optionally delete the callee. + // A caller in the callee's own file is one physical source plan. Remove + // the declaration from the rewritten candidate, never from its old bytes. var calleeNewContent []byte - calleeDeleted := false + calleeDeleted, calleeInCaller := false, false if deleteAfter { - spliced, derr := spliceOutGoDecl(calleeContent, calleeFset, calleeDocless) - if derr != nil { - return mcp.NewToolResultError(derr.Error()), nil + for i := range patches { + if patches[i].absPath != calleeAbs { + continue + } + patchedFset := token.NewFileSet() + patchedFile, parseErr := parser.ParseFile(patchedFset, calleeAbs, patches[i].patched, parser.ParseComments) + if parseErr != nil { + return mcp.NewToolResultError(parseErr.Error()), nil + } + _, declaration, locateErr := findGoFunc(patchedFile, node.Name) + if locateErr != nil { + return mcp.NewToolResultError(locateErr.Error()), nil + } + spliced, spliceErr := spliceOutGoDecl(patches[i].patched, patchedFset, declaration) + if spliceErr != nil { + return mcp.NewToolResultError(spliceErr.Error()), nil + } + patches[i].patched = spliced + calleeInCaller = true + break + } + if !calleeInCaller { + spliced, spliceErr := spliceOutGoDecl(calleeContent, calleeFset, calleeDocless) + if spliceErr != nil { + return mcp.NewToolResultError(spliceErr.Error()), nil + } + calleeNewContent = spliced } - calleeNewContent = spliced calleeDeleted = true } - type touched struct { Path string `json:"path"` Role string `json:"role"` @@ -1274,7 +1321,7 @@ func (s *Server) handleInlineSymbol(ctx context.Context, req mcp.CallToolRequest ImportRem: p.removedImp, }) } - if calleeDeleted { + if calleeDeleted && !calleeInCaller { touchedList = append(touchedList, touched{ Path: s.repoRelative(calleeAbs), Role: "callee_deleted", @@ -1302,25 +1349,59 @@ func (s *Server) handleInlineSymbol(ctx context.Context, req mcp.CallToolRequest return s.respondJSONOrTOON(ctx, req, resp) } - for _, p := range patches { - if err := writeMoveFile(p.absPath, p.patched); err != nil { + plans := make([]plannedSource, 0, len(patches)+1) + for _, patch := range patches { + plans = append(plans, plannedSource{path: patch.absPath, before: patch.orig, existed: true}) + } + if calleeDeleted && !calleeInCaller { + plans = append(plans, plannedSource{path: calleeAbs, before: calleeContent, existed: true}) + } + ctx, release, lockErr := lockPlannedSources(ctx, plans) + if lockErr != nil { + return mcp.NewToolResultError(lockErr.Error()), nil + } + defer release() + ctx = context.WithoutCancel(ctx) + physical := make(map[string]*mutationCommitRecord, len(plans)) + var targets []sourceMutationTarget + completed := false + finish := func() { + if completed { + return + } + completed = true + admissions := s.admitSourceMutationFrontier(ctx, targets) + release() + for i, outcome := range s.awaitSourceMutationFrontier(ctx, admissions) { + physical[targets[i].path].recordGraph(outcome) + } + } + defer finish() + + for _, patch := range patches { + record, err := s.writeMoveFile(ctx, "inline_symbol", patch.absPath, patch.patched) + if err != nil { return mcp.NewToolResultError(err.Error()), nil } + physical[patch.absPath] = record + targets = append(targets, captureSourceMutationTarget(ctx, patch.absPath)) } - if calleeDeleted { - if err := writeMoveFile(calleeAbs, calleeNewContent); err != nil { + if calleeDeleted && !calleeInCaller { + record, err := s.writeMoveFile(ctx, "inline_symbol", calleeAbs, calleeNewContent) + if err != nil { return mcp.NewToolResultError(err.Error()), nil } + physical[calleeAbs] = record + targets = append(targets, captureSourceMutationTarget(ctx, calleeAbs)) } + finish() sess := s.sessionFor(ctx) for _, p := range patches { sess.recordModified(s.repoRelative(p.absPath)) - _ = s.reindexFile(p.absPath) } - if calleeDeleted { + if calleeDeleted && !calleeInCaller { sess.recordModified(s.repoRelative(calleeAbs)) - _ = s.reindexFile(calleeAbs) } return s.respondJSONOrTOON(ctx, req, resp) diff --git a/internal/mcp/tools_mutation_status.go b/internal/mcp/tools_mutation_status.go index c25cfdece..eb3ebabfa 100644 --- a/internal/mcp/tools_mutation_status.go +++ b/internal/mcp/tools_mutation_status.go @@ -42,10 +42,11 @@ func (s *Server) handleMutationStatus(ctx context.Context, req mcp.CallToolReque return s.respondJSONOrTOON(ctx, req, payload) } } - if !ok || !mutationCommitInScope(ctx, record) { - return mcp.NewToolResultError( - "no mutation receipt " + receipt + " — receipts are kept for " + mutationCommitRetention.String() + - "; query by path instead, or read the file to see its current state"), nil + if ok && !mutationCommitInScope(ctx, record) { + return mcp.NewToolResultError("no mutation receipt " + receipt), nil + } + if !ok { + return s.respondJSONOrTOON(ctx, req, lostMutationPayload("receipt", receipt)) } return s.respondJSONOrTOON(ctx, req, s.mutationStatusPayload(record)) @@ -56,12 +57,14 @@ func (s *Server) handleMutationStatus(ctx context.Context, req mcp.CallToolReque payload["note"] = "this is a graph freshness receipt id, not a caller mutation_id" return s.respondJSONOrTOON(ctx, req, payload) } - return mcp.NewToolResultError("no mutation recorded for mutation_id " + mutationID + - " — use change.receipt options.receipt=" + mutationID + " to query a graph freshness receipt"), nil + return s.respondJSONOrTOON(ctx, req, lostMutationPayload("mutation_id", mutationID)) } - if !ok || !mutationCommitInScope(ctx, record) { + if ok && !mutationCommitInScope(ctx, record) { return mcp.NewToolResultError("no mutation recorded for mutation_id " + mutationID), nil } + if !ok { + return s.respondJSONOrTOON(ctx, req, lostMutationPayload("mutation_id", mutationID)) + } return s.respondJSONOrTOON(ctx, req, s.mutationStatusPayload(record)) case rawPath != "": @@ -163,6 +166,7 @@ func (s *Server) graphRefreshReceiptPayload(ctx context.Context, id string) (map } attachMutationPhaseTimings(payload, receipt.id, time.Time{}, indexer.PublicationSourceFreshRequest, retained) attachAcknowledgementPayload(payload, acknowledgement, acknowledgementState) + attachReceiptTiers(payload, receipt) return payload, true } @@ -202,6 +206,7 @@ func (s *Server) mutationStatusPayload(record *mutationCommitRecord) map[string] s.refreshPendingSourceRecord(record) snap := record.snapshot() payload := map[string]any{ + "ack_mode": snap.AckMode, "found": true, "receipt": snap.Receipt, "tool": snap.Tool, @@ -216,6 +221,9 @@ func (s *Server) mutationStatusPayload(record *mutationCommitRecord) map[string] if snap.MutationID != "" { payload["mutation_id"] = snap.MutationID } + if snap.ContentSHA256 != "" { + payload["content_sha256"] = snap.ContentSHA256 + } if snap.NewSHA != "" { payload["new_sha"] = snap.NewSHA } @@ -248,6 +256,16 @@ func (s *Server) mutationStatusPayload(record *mutationCommitRecord) map[string] acknowledgement = &snapshot } attachAcknowledgementPayload(payload, acknowledgement, "unavailable_recording_or_evicted") + publicationReceipt := snap.ReindexReceipt + if publicationReceipt == "" { + publicationReceipt = snap.publicationReceipt + } + if value, found := s.mutationReceipts.Load(publicationReceipt); found { + if receipt, ok := value.(*mutationReceipt); ok { + attachReceiptTiers(payload, receipt) + } + } + record.attachFileProjection(payload) return payload } @@ -382,6 +400,9 @@ func mutationCommitListingPayload(records []*mutationCommitRecord) []map[string] "graph_status_terminal": graphStatusTerminal(snap.GraphStatus, snap.GraphRecorded), } attachMutationRefreshSnapshot(entry, snap) + if snap.AckMode != "" { + entry["ack_mode"] = snap.AckMode + } // Every remaining field mirrors mutationCommitSnapshot's omitempty // exactly. Rendering by hand is what dropped new_sha and // bytes_written the first time, so the parity is asserted against the @@ -396,6 +417,9 @@ func mutationCommitListingPayload(records []*mutationCommitRecord) []map[string] if snap.MutationID != "" { entry["mutation_id"] = snap.MutationID } + if snap.ContentSHA256 != "" { + entry["content_sha256"] = snap.ContentSHA256 + } if snap.NewSHA != "" { entry["new_sha"] = snap.NewSHA } diff --git a/internal/mcp/tools_overlay_branch.go b/internal/mcp/tools_overlay_branch.go index fddc5c4e2..7b1e52ea4 100644 --- a/internal/mcp/tools_overlay_branch.go +++ b/internal/mcp/tools_overlay_branch.go @@ -9,7 +9,6 @@ import ( "github.com/mark3labs/mcp-go/mcp" - "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/query" @@ -259,13 +258,45 @@ func (s *Server) handleOverlayMerge(ctx context.Context, req mcp.CallToolRequest } var writer daemon.DiskWriteFn + var writes []committedSourceMutation + release := func() {} if toDisk { - // Capture the resolver closure once; each per-file write - // re-resolves, re-checks drift, and atomically writes. - writer = s.diskWriteForMerge(ctx) + // Resolve and own the complete bounded file set before taking the + // branch mutex. The manager then verifies that exact source cohort + // before the first callback, without waiting for publication inside it. + files, err := s.overlays.FilesForBranchBounded(id, from, overlayRequestSnapshotMaxFiles, overlayRequestSnapshotMaxBytes) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + opts.ExpectedFiles = append(make([]daemon.OverlayFile, 0, len(files)), files...) + paths := make([]string, 0, len(files)) + for _, file := range files { + path, _, err := s.resolveFilePath(ctx, file.Path) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + paths = append(paths, path) + } + ctx, release, err = acquireMutationPaths(ctx, paths) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + defer release() + writer = func(file daemon.OverlayFile) error { + write, err := s.writeOverlayMergeFileHeld(ctx, file) + if write.record != nil { + writes = append(writes, write) + } + return err + } } res, mergeErr := s.overlays.MergeBranches(id, opts, writer) + if toDisk { + // The branch mutex is now released, including on a later-file error. + // Every successfully committed file still receives its one admission. + s.completeCommittedSourceMutations(ctx, writes, release) + } if mergeErr != nil { switch { case errors.Is(mergeErr, daemon.ErrSessionNotFound): @@ -321,60 +352,56 @@ func (s *Server) handleOverlayMerge(ctx context.Context, req mcp.CallToolRequest // reuse, drift detection would be duplicated and could silently // diverge across surfaces. func (s *Server) diskWriteForMerge(ctx context.Context) daemon.DiskWriteFn { - return func(f daemon.OverlayFile) error { - absPath, _, resolveErr := s.resolveFilePath(ctx, f.Path) - if resolveErr != nil { - return fmt.Errorf("merge to_disk: %s: %w", f.Path, resolveErr) + return func(file daemon.OverlayFile) error { + path, _, err := s.resolveFilePath(ctx, file.Path) + if err != nil { + return fmt.Errorf("merge to_disk: %s: %w", file.Path, err) } - if f.Deleted { - // Tombstone-on-disk: remove the file. Honour the - // drift guard before removal so a concurrent edit - // can't be silently overwritten by an unlink. - if f.BaseSHA != "" { - current, readErr := os.ReadFile(absPath) - if readErr != nil { - // File already gone is not drift; surface - // the error only when something other than - // "not found" is at play. - if errors.Is(readErr, os.ErrNotExist) { - return nil - } - return readErr - } - if gitBlobSHA(current) != normalizeExpectedSHA(f.BaseSHA) { - return fmt.Errorf("%s: %w", f.Path, daemon.ErrOverlayDrift) - } - } - if rmErr := os.Remove(absPath); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) { - return fmt.Errorf("merge to_disk: remove %s: %w", absPath, rmErr) - } - s.reindexFile(absPath) - return nil + held, release, err := acquireMutationPath(ctx, path) + if err != nil { + return err } - if f.BaseSHA != "" { - current, readErr := os.ReadFile(absPath) - if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { - return readErr - } - if readErr == nil && gitBlobSHA(current) != normalizeExpectedSHA(f.BaseSHA) { - return fmt.Errorf("%s: %w", f.Path, daemon.ErrOverlayDrift) - } - if readErr != nil && f.BaseSHA != "" { - // File expected on disk but missing — treat as - // drift so the caller re-reads + resubmits. - return fmt.Errorf("%s: %w", f.Path, daemon.ErrOverlayDrift) - } + defer release() + write, err := s.writeOverlayMergeFileHeld(held, file) + if write.record != nil { + s.completeCommittedSourceMutations(held, []committedSourceMutation{write}, release) + } + return err + } +} + +// Only physical I/O belongs in the manager callback; admission and graph waits +// belong to the operation's final frontier after the manager releases its mutex. +func (s *Server) writeOverlayMergeFileHeld(ctx context.Context, file daemon.OverlayFile) (committedSourceMutation, error) { + path, _, err := s.resolveFilePath(ctx, file.Path) + if err != nil { + return committedSourceMutation{}, fmt.Errorf("merge to_disk: %s: %w", file.Path, err) + } + if file.BaseSHA != "" { + current, readErr := os.ReadFile(path) + if file.Deleted && errors.Is(readErr, os.ErrNotExist) { + return committedSourceMutation{}, nil } - perm := os.FileMode(0o644) - if info, statErr := os.Stat(absPath); statErr == nil { - perm = info.Mode().Perm() + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return committedSourceMutation{}, readErr } - if err := agents.AtomicWriteFile(absPath, []byte(f.Content), perm); err != nil { - return fmt.Errorf("merge to_disk: write %s: %w", absPath, err) + if readErr != nil || gitBlobSHA(current) != normalizeExpectedSHA(file.BaseSHA) { + return committedSourceMutation{}, fmt.Errorf("%s: %w", file.Path, daemon.ErrOverlayDrift) } - s.reindexFile(absPath) - return nil } + perm := os.FileMode(0o644) + if info, statErr := os.Stat(path); statErr == nil { + perm = info.Mode().Perm() + } + operation := sourceMutationOperation{kind: sourceWrite} + if file.Deleted { + operation.kind = sourceRemove + } + commit, err := s.commitFileMutation(ctx, "overlay_merge", "", "", file.Path, path, []byte(file.Content), perm, operation) + if err != nil { + return committedSourceMutation{}, fmt.Errorf("merge to_disk: %s: %w", file.Path, err) + } + return captureCommittedSourceMutation(ctx, path, commit), nil } // handleCompareBranches runs the supplied query kind against two diff --git a/internal/mcp/tools_safe_delete.go b/internal/mcp/tools_safe_delete.go index 08ac47ad4..402bc5993 100644 --- a/internal/mcp/tools_safe_delete.go +++ b/internal/mcp/tools_safe_delete.go @@ -5,12 +5,14 @@ import ( "fmt" "os" "path/filepath" + "reflect" "sort" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" ) // registerSafeDeleteSymbolTool wires safe_delete_symbol — atomic @@ -118,7 +120,21 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq for _, site := range provisional { refs = append(refs, safeDeleteReference{Kind: "provisional", FilePath: site.File, Line: site.Line}) } + var finishWrites func() + var primaryAbs string + var primaryCommit *mutationCommitRecord + var primaryOutcome mutationReindexOutcome respond := func(result map[string]any) (*mcp.CallToolResult, error) { + if finishWrites != nil { + finishWrites() + } + if primaryCommit != nil { + if primaryOutcome.Err != nil { + result["reindex_error"] = primaryOutcome.Err.Error() + } + s.attachMutationFreshness(result, node.FilePath, primaryAbs, primaryOutcome) + attachMutationCommit(result, primaryCommit) + } label := s.labelRelations(ctx, gaps, []string{id}, nil) label.apply(result) res, err := s.respondJSONOrTOON(ctx, req, result) @@ -129,8 +145,14 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq // refusing. Builds a per-caller plan, applies the removable standalone // calls (parse-gate validated), and flags embedded references as manual. var appliedPatches, failedPatches []callerPatch + var propagationPlan []callerPatch if req.GetBool("propagate", false) && len(refs) > 0 { - plan := s.buildPropagationPlan(ctx, g, node) + var plan []callerPatch + if dryRun { + plan = s.buildPropagationPreview(ctx, g, node) + } else { + plan = s.buildPropagationPlan(ctx, g, node) + } for _, site := range provisional { reason := "provisional: named in a file whose edit the graph has not read yet — patch by hand" if site.Line == 0 { @@ -142,16 +164,25 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq } manual := countManual(plan) if dryRun { + needsRevalidation := false + for _, patch := range plan { + needsRevalidation = needsRevalidation || patch.NeedsSourceRevalidation + } + hint := "re-run with dry_run=false to apply the verified remove_line patches and delete the symbol; resolve manual patches first or pass force=true" + if needsRevalidation { + hint = "this is a source preview, not write authorization: publish or revalidate the changed source and scoped references before requesting apply" + } return respond(map[string]any{ - "status": "propagation_plan", - "symbol": id, - "file": node.FilePath, - "caller_patches": plan, - "removable": len(plan) - manual, - "manual": manual, - "reference_count": len(refs), - "dry_run": true, - "hint": "re-run with dry_run=false to apply the remove_line patches and delete the symbol; resolve manual patches first or pass force=true", + "status": "propagation_plan", + "source_revalidation_required": needsRevalidation, + "symbol": id, + "file": node.FilePath, + "caller_patches": plan, + "removable": len(plan) - manual, + "manual": manual, + "reference_count": len(refs), + "dry_run": true, + "hint": hint, }) } if manual > 0 && !force { @@ -163,38 +194,10 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq "hint": fmt.Sprintf("%d call site(s) need manual patching; resolve them or pass force=true to delete anyway", manual), }) } - applied, failed, perr := s.applyRemoveLinePatches(plan) - if perr != nil { - return mcp.NewToolResultError(perr.Error()), nil - } - appliedPatches, failedPatches = applied, failed - if len(failed) > 0 && !force { - return respond(map[string]any{ - "status": "propagation_partial", - "symbol": id, - "patches_applied": applied, - "patches_failed": failed, - "hint": "some removals would break their file's syntax and were skipped; patch them by hand or pass force=true to delete the symbol anyway", - }) - } - // Callers patched — proceed with the deletion. Re-fetch the node in - // case a same-file removal shifted its line range. - force = true - node = g.GetNode(id) - if node == nil { - return respond(map[string]any{ - "status": "deleted_by_propagation", - "symbol": id, - "patches_applied": appliedPatches, - "note": "the symbol's definition was removed as a side effect of patching (it no longer resolves in the graph)", - }) - } - if node.StartLine == 0 || node.EndLine == 0 { - return mcp.NewToolResultError("symbol has no line range after propagation: " + id), nil - } + propagationPlan = plan } - if len(refs) > 0 && !force { + if len(refs) > 0 && !force && len(propagationPlan) == 0 { return respond(map[string]any{ "status": "rejected_has_references", "symbol": id, @@ -270,14 +273,24 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq return mcp.NewToolResultError(refuseUTF16Edit("safe_delete_symbol", node.FilePath).Error()), nil } lines := strings.Split(string(content), "\n") - if node.StartLine > len(lines) || node.EndLine > len(lines) { - return mcp.NewToolResultError("symbol line range exceeds file length"), nil + if dryRun { + start, end, err := s.nodeLinesIn(ctx, node, content) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + resolved := *node + resolved.StartLine, resolved.EndLine = start, end + node = &resolved + } + // An actual delete resolves again only under its complete held path set. + // These initial response fields are replaced by the applied snapshot. + deleteStart, deleteEnd, linesDeleted := 0, 0, 0 + deletedChunk := "" + if node.StartLine >= 1 && node.EndLine >= node.StartLine && node.EndLine <= len(lines) { + deleteStart, deleteEnd = expandDeleteRange(node, lines) + deletedChunk = strings.Join(lines[deleteStart-1:deleteEnd], "\n") + linesDeleted = deleteEnd - deleteStart + 1 } - - deleteStart, deleteEnd := expandDeleteRange(node, lines) - - deletedChunk := strings.Join(lines[deleteStart-1:deleteEnd], "\n") - linesDeleted := deleteEnd - deleteStart + 1 result := map[string]any{ "symbol": id, @@ -333,29 +346,119 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq } } - deletedIDs, err := s.applyPendingDeletes(ctx, pending) + // One final union lock covers every source effect. A wait behind another + // writer cannot silently reuse the old reference/closure/patch authority. + paths := make([]string, 0, len(pending)+len(propagationPlan)) + for _, item := range pending { + paths = append(paths, item.abs) + } + for _, patch := range propagationPlan { + if patch.Action == "remove_line" { + paths = append(paths, patch.abs) + } + } + ctx, releasePaths, lockErr := acquireMutationPaths(ctx, paths) + if lockErr != nil { + return mcp.NewToolResultError("lock delete frontier: " + lockErr.Error()), nil + } + defer releasePaths() + primaryAbs = absPath + primaryKey := pathkey.MutationKey(absPath) + var writes []committedSourceMutation + finished := false + finishWrites = func() { + if finished { + return + } + finished = true + final, outcomes := s.completeCommittedSourceMutations(ctx, writes, releasePaths) + for i, write := range final { + if write.target.key == primaryKey { + primaryCommit, primaryOutcome = write.record, outcomes[i] + } + } + } + defer finishWrites() + currentNode := g.GetNode(id) + if currentNode == nil || currentNode.FilePath != node.FilePath { + return respond(map[string]any{"status": "source_authority_changed", "symbol": id, "written": false, "hint": "the selected declaration changed while acquiring the complete source frontier; request a new plan"}) + } + currentGaps := s.laggingMutationReceipts(ctx) + currentProvisional := s.provisionalSites(g, currentGaps, currentNode) + currentRefs := collectReferencingEdges(g, id) + for _, site := range currentProvisional { + currentRefs = append(currentRefs, safeDeleteReference{Kind: "provisional", FilePath: site.File, Line: site.Line}) + } + if !sameSafeDeleteReferences(refs, currentRefs) || !reflect.DeepEqual(provisional, currentProvisional) { + return respond(map[string]any{"status": "source_authority_changed", "symbol": id, "written": false, "hint": "references or unpublished source changed while acquiring the complete source frontier; request a new plan"}) + } + if cascadeMode != cascadeModeOff { + currentClosure, truncated := computeCascadeClosure(g, currentNode, cascadeIntoTests) + if truncated != cascadeTruncated || !reflect.DeepEqual(closure, currentClosure) { + return respond(map[string]any{"status": "source_authority_changed", "symbol": id, "written": false, "hint": "cascade membership changed while acquiring the complete source frontier; request a new plan"}) + } + for _, member := range pending[1:] { + if len(s.provisionalSites(g, currentGaps, member.node)) > 0 { + return respond(map[string]any{"status": "rejected_has_references", "symbol": id, "written": false, "hint": "a cascade member gained an unpublished reference; publish it and request a new plan"}) + } + } + } + if len(propagationPlan) > 0 { + // Original provisional rows stay manual. Compare exact graph-derived + // occurrences and accepted source identities before the first write. + currentPlan := s.buildPropagationPlan(ctx, g, currentNode) + var originalGraphPlan []callerPatch + for _, patch := range propagationPlan { + if patch.kind != "" { + originalGraphPlan = append(originalGraphPlan, patch) + } + } + if !reflect.DeepEqual(originalGraphPlan, currentPlan) { + return respond(map[string]any{"status": "source_authority_changed", "symbol": id, "written": false, "hint": "caller occurrences or accepted bytes changed while acquiring the complete source frontier; request a new plan"}) + } + var callerWrites []committedSourceMutation + var propagationErr error + appliedPatches, failedPatches, callerWrites, propagationErr = s.applyRemoveLinePatchesHeld(ctx, propagationPlan) + writes = append(writes, callerWrites...) + if propagationErr != nil || len(failedPatches) > 0 && !force { + partial := map[string]any{"status": "propagation_partial", "symbol": id, "patches_applied": appliedPatches, "patches_failed": failedPatches, "hint": "some source patches committed but the complete removal did not finish; inspect these exact effects before retrying"} + if propagationErr != nil { + partial["error"] = propagationErr.Error() + } + return respond(partial) + } + refs = referencesAfterCommittedPatches(refs, appliedPatches, id) + if len(refs) > 0 && !force { + return respond(map[string]any{"status": "rejected_has_references", "symbol": id, "references": refs, "reference_count": len(refs), "patches_applied": appliedPatches, "patches_failed": failedPatches, "hint": "unplanned or unverified references remain; patch them or explicitly request force"}) + } + result["references"], result["reference_count"] = refs, len(refs) + } + deletedIDs, definitionWrites, err := s.applyPendingDeletesHeld(ctx, pending) + writes = append(writes, definitionWrites...) if err != nil { - // Fail-fast: surface what was done up to this point so the - // caller can recover. Treat the failure as a tool error. + if len(deletedIDs) == 0 && len(appliedPatches) == 0 { + return mcp.NewToolResultError(err.Error()), nil + } + // Surface only source effects whose physical commit succeeded. result["status"] = "partial_failure" result["cascade_deleted"] = deletedIDs result["error"] = err.Error() + result["patches_applied"], result["patches_failed"] = appliedPatches, failedPatches return respond(result) } + result["start_line"], result["end_line"] = pending[0].start, pending[0].end + result["lines_deleted"] = pending[0].end - pending[0].start + 1 + result["preview"] = pending[0].preview + // Persist session state and re-index every touched file. sess := s.sessionFor(ctx) - touchedFiles := make(map[string]struct{}, len(pending)) for _, p := range pending { sess.recordModified(p.node.FilePath) sess.recordSymbol(p.node.ID) if s.symHistory != nil { s.symHistory.Record(p.node.ID, true) } - touchedFiles[p.abs] = struct{}{} - } - for abs := range touchedFiles { - s.reindexFile(abs) } result["status"] = "deleted" @@ -386,14 +489,14 @@ func normaliseCascadeMode(raw string) string { } // pendingDelete buffers everything needed to delete one symbol from -// its file. start/end of zero mean "compute lazily from the node and -// the file contents" — only the original target arrives pre-computed -// because the dry_run preview already needed those numbers. +// its file. Every span is resolved against the bytes read under the held +// path set; the response retains that actual removed snapshot. type pendingDelete struct { - node *graph.Node - abs string - start int - end int + node *graph.Node + abs string + start int + end int + preview string } // applyPendingDeletes groups pending deletes by absolute file path @@ -402,7 +505,7 @@ type pendingDelete struct { // rewritten once. Returns the IDs of symbols whose bytes were // removed; on first error, the partial list rides alongside the // error. -func (s *Server) applyPendingDeletes(ctx context.Context, pending []*pendingDelete) ([]string, error) { +func (s *Server) applyPendingDeletesHeld(ctx context.Context, pending []*pendingDelete) ([]string, []committedSourceMutation, error) { byFile := map[string][]*pendingDelete{} order := []string{} for _, p := range pending { @@ -413,28 +516,29 @@ func (s *Server) applyPendingDeletes(ctx context.Context, pending []*pendingDele } deleted := make([]string, 0, len(pending)) + var writes []committedSourceMutation for _, abs := range order { bucket := byFile[abs] content, err := os.ReadFile(abs) if err != nil { - return deleted, fmt.Errorf("could not read %s: %v", abs, err) + return deleted, writes, fmt.Errorf("could not read %s: %v", abs, err) } // Refuse UTF-16 sources before splicing raw bytes (#846) — this is // the shared delete writer, so cascade targets are covered too. if indexer.LooksUTF16Source(content) { - return deleted, refuseUTF16Edit("safe_delete_symbol", abs) + return deleted, writes, refuseUTF16Edit("safe_delete_symbol", abs) } lines := strings.Split(string(content), "\n") - // Materialise ranges for entries that arrived with zero - // start/end (the cascade tail; the original target arrives - // pre-computed for parity with the dry_run preview). for _, p := range bucket { - if p.start == 0 || p.end == 0 { - if p.node.StartLine > len(lines) || p.node.EndLine > len(lines) { - return deleted, fmt.Errorf("symbol %s line range exceeds file %s", p.node.ID, abs) - } - p.start, p.end = expandDeleteRange(p.node, lines) + start, end, err := s.symbolLinesUnderLock(ctx, p.node, abs, content) + if err != nil { + return deleted, writes, err } + resolved := *p.node + resolved.StartLine, resolved.EndLine = start, end + p.node = &resolved + p.start, p.end = expandDeleteRange(p.node, lines) + p.preview = strings.Join(lines[p.start-1:p.end], "\n") } // Sort descending so deletions earlier in the file don't // shift the line indexes of later ones. @@ -448,13 +552,12 @@ func (s *Server) applyPendingDeletes(ctx context.Context, pending []*pendingDele // failure surface clean. for i := 0; i < len(bucket)-1; i++ { if bucket[i].start <= bucket[i+1].end { - return deleted, fmt.Errorf("overlapping delete ranges in %s for %s and %s", + return deleted, writes, fmt.Errorf("overlapping delete ranges in %s for %s and %s", abs, bucket[i].node.ID, bucket[i+1].node.ID) } } for _, p := range bucket { lines = append(lines[:p.start-1], lines[p.end:]...) - deleted = append(deleted, p.node.ID) } newContent := strings.Join(lines, "\n") // The UTF-16 refusal above is the early, per-file UX; the commit @@ -477,11 +580,16 @@ func (s *Server) applyPendingDeletes(ctx context.Context, pending []*pendingDele if info, err := os.Stat(abs); err == nil { perm = info.Mode().Perm() } - if _, werr := s.commitFileMutation(ctx, "safe_delete_symbol", "", "", rel, abs, []byte(newContent), perm); werr != nil { - return deleted, fmt.Errorf("could not write %s: %v", abs, werr) + commit, werr := s.commitFileMutation(ctx, "safe_delete_symbol", "", "", rel, abs, []byte(newContent), perm) + if werr != nil { + return deleted, writes, fmt.Errorf("could not write %s: %v", abs, werr) + } + writes = append(writes, captureCommittedSourceMutation(ctx, abs, commit)) + for _, p := range bucket { + deleted = append(deleted, p.node.ID) } } - return deleted, nil + return deleted, writes, nil } // expandDeleteRange grows a symbol's [StartLine, EndLine] range to @@ -707,15 +815,17 @@ func collectReferencingEdges(g graph.Reader, id string) []safeDeleteReference { if !isReferencingEdgeKind(e.Kind) { continue } - key := e.From + "|" + string(e.Kind) + key := fmt.Sprintf("%s|%s|%s|%d", e.From, e.Kind, e.FilePath, e.Line) if seen[key] { continue } seen[key] = true - row := safeDeleteReference{FromID: e.From, Kind: string(e.Kind)} + row := safeDeleteReference{FromID: e.From, Kind: string(e.Kind), Line: e.Line, FilePath: e.FilePath} if from := g.GetNode(e.From); from != nil { row.FromName = from.Name - row.FilePath = from.FilePath + if row.FilePath == "" { + row.FilePath = from.FilePath + } } out = append(out, row) } @@ -753,3 +863,37 @@ func isCommentLine(trimmed string) bool { } return false } + +func sameSafeDeleteReferences(a, b []safeDeleteReference) bool { + if len(a) != len(b) { + return false + } + counts := make(map[safeDeleteReference]int, len(a)) + for _, ref := range a { + counts[ref]++ + } + for _, ref := range b { + if counts[ref] == 0 { + return false + } + counts[ref]-- + } + return true +} + +func referencesAfterCommittedPatches(refs []safeDeleteReference, applied []callerPatch, target string) []safeDeleteReference { + var remaining []safeDeleteReference + for _, ref := range refs { + removed := false + for _, patch := range applied { + if patch.sourceHash != ([32]byte{}) && patch.target == target && patch.from == ref.FromID && string(patch.kind) == ref.Kind && patch.File == ref.FilePath && patch.Line == ref.Line && patch.Line > 0 { + removed = true + break + } + } + if !removed { + remaining = append(remaining, ref) + } + } + return remaining +} diff --git a/internal/mcp/tools_safe_delete_test.go b/internal/mcp/tools_safe_delete_test.go index 6d740a552..eb1fe80ca 100644 --- a/internal/mcp/tools_safe_delete_test.go +++ b/internal/mcp/tools_safe_delete_test.go @@ -11,13 +11,16 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "go.uber.org/zap" ) // newSafeDeleteTestServer builds a server with one indexed file // containing two symbols: Live (called by Caller) and DeadCode -// (unused). resolveNodePath uses the indexer root, so the test -// server fakes an indexer with the temp root. +// (unused). The absolute graph-path fixture has a real parser-owning indexer +// so current-byte declaration resolution does not trust its manual spans. func newSafeDeleteTestServer(t *testing.T) (*Server, string) { t.Helper() @@ -58,7 +61,10 @@ func Caller() int { }) g.AddEdge(&graph.Edge{From: abs + "::Caller", To: abs + "::Live", Kind: graph.EdgeCalls}) + idx := indexer.New(g, testRegistry(), config.Default().Index, zap.NewNop()) + idx.SetRootPath(dir) s := &Server{ + indexer: idx, graph: g, session: newSessionState(), tokenStats: &tokenStats{}, diff --git a/internal/mcp/view_answer_identity_test.go b/internal/mcp/view_answer_identity_test.go index 96f989256..58f64e11e 100644 --- a/internal/mcp/view_answer_identity_test.go +++ b/internal/mcp/view_answer_identity_test.go @@ -294,7 +294,7 @@ func TestMutationReceiptRendersPublicationPhases(t *testing.T) { ctx, _ = withMutationCommitNote(ctx) ctx = withCheckoutMutation(ctx, mutation, filepath.Dir(path)) data := []byte("package committed\n") - record, err := s.commitFileMutation(ctx, "write_file", "", "", "edit.go", path, data, 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "edit.go", path, data, 0o600) require.NoError(t, err) sum := sha256.Sum256(data) mutation.ticket.ContentHash = hex.EncodeToString(sum[:]) diff --git a/internal/mcp/view_freshness_test.go b/internal/mcp/view_freshness_test.go index a4a69ee34..6577624c5 100644 --- a/internal/mcp/view_freshness_test.go +++ b/internal/mcp/view_freshness_test.go @@ -131,6 +131,9 @@ func TestViewsGuideStatesTheRequestLevelFreshnessContract(t *testing.T) { // knobValue is a well-formed value for one request-level knob. func knobValue(knob string) any { + if knob == freshnessScopeArgName { + return "file" + } if knob == waitDeadlineArgName { return time.Now().Add(time.Minute).Format(time.RFC3339) } diff --git a/internal/mcp/view_mutation_known_write_test.go b/internal/mcp/view_mutation_known_write_test.go index 043f7bfb1..df220cb7b 100644 --- a/internal/mcp/view_mutation_known_write_test.go +++ b/internal/mcp/view_mutation_known_write_test.go @@ -56,7 +56,7 @@ func TestEditAdmissionHandsTheCommittedWriteToTheCapture(t *testing.T) { ctx, _ = withMutationCommitNote(ctx) ctx = withCheckoutMutation(ctx, mutation, filepath.Dir(path)) data := []byte("package committed\n") - _, err := s.commitFileMutation(ctx, "write_file", "", "", "edit.go", path, data, 0o600) + _, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "edit.go", path, data, 0o600) require.NoError(t, err) sum := sha256.Sum256(data) inner.ticket.ContentHash = hex.EncodeToString(sum[:]) diff --git a/internal/mcp/view_mutation_state_test.go b/internal/mcp/view_mutation_state_test.go index a66adb586..229b07a12 100644 --- a/internal/mcp/view_mutation_state_test.go +++ b/internal/mcp/view_mutation_state_test.go @@ -188,11 +188,11 @@ func TestCheckoutMutationResolvedRootRejectsFoldedDistinctDirectory(t *testing.T func TestCheckoutMutationPathLockRefusesOtherCheckout(t *testing.T) { root := t.TempDir() ctx := withCheckoutMutation(context.Background(), &fakeCheckoutMutationLifecycle{}, root) - release, err := acquireMutationPath(ctx, filepath.Join(t.TempDir(), "file.go")) + _, release, err := acquireMutationPath(ctx, filepath.Join(t.TempDir(), "file.go")) if err == nil || release != nil { t.Fatalf("lock acquired outside selected checkout: release=%v err=%v", release != nil, err) } - release, err = acquireMutationPath(ctx, filepath.Join(root, "file.go")) + _, release, err = acquireMutationPath(ctx, filepath.Join(root, "file.go")) if err != nil { t.Fatal(err) } @@ -217,7 +217,7 @@ func TestCheckoutMutationOutsideFinalSymlinkPreserved(t *testing.T) { }} ctx := withCheckoutMutation(context.Background(), lease, root) s := &Server{} - record, err := s.commitFileMutation(ctx, "write_file", "", "test", "link.go", outsideLink, []byte("package changed\n"), 0o644) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "test", "link.go", outsideLink, []byte("package changed\n"), 0o644) if !errors.Is(err, errMutationNotApplied) || prepared || record.diskStatus() != mutationDiskNotApplied { t.Fatalf("outside link was admitted: prepared=%v receipt=%+v err=%v", prepared, record.snapshot(), err) } @@ -265,7 +265,7 @@ func TestCheckoutMutationCommitPreparesBeforeDisk(t *testing.T) { }} ctx := withCheckoutMutation(context.Background(), lease, root) s := &Server{} - record, err := s.commitFileMutation(ctx, "write_file", "", "test", "file.go", path, []byte("package changed\n"), 0o644) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "test", "file.go", path, []byte("package changed\n"), 0o644) if err != nil || !prepared || record.diskStatus() != mutationDiskCommitted { t.Fatalf("commit prepared=%v record=%+v err=%v", prepared, record.snapshot(), err) } @@ -295,7 +295,7 @@ func TestCheckoutMutationPrepareFailureLeavesDiskUntouched(t *testing.T) { }} ctx = withCheckoutMutation(ctx, lease, root) s := &Server{} - record, err := s.commitFileMutation(ctx, "write_file", "", "test", "file.go", path, []byte("package changed\n"), 0o644) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "test", "file.go", path, []byte("package changed\n"), 0o644) if !errors.Is(err, errMutationNotApplied) || record.diskStatus() != mutationDiskNotApplied { t.Fatalf("expected not-applied receipt: %+v, %v", record.snapshot(), err) } diff --git a/internal/mcp/view_mutation_write_intent.go b/internal/mcp/view_mutation_write_intent.go index a10bfb24a..8f2ba578d 100644 --- a/internal/mcp/view_mutation_write_intent.go +++ b/internal/mcp/view_mutation_write_intent.go @@ -7,10 +7,9 @@ import ( type sourceMutationWriteIntentKey struct{} -// sourceMutationWriteIntent belongs to one handler. Publication waits suspend -// its store announcement; mutation lane/cycle admission and disk writes retain -// ordinary writer preemption. The selected route is still revalidated by the -// existing mutation admission after each wait. +// sourceMutationWriteIntent belongs to one store-writing checkout handler. +// Publication waits suspend its announcement. The selected route is still +// revalidated by mutation admission after each wait. type sourceMutationWriteIntent struct { mu sync.Mutex announce func() func() @@ -24,6 +23,21 @@ func (s *Server) withSourceMutationWriteIntent(ctx context.Context, tool string) s.materializer == nil || s.materializer.Store == nil { return ctx, func() {} } + // Canonical source edits only write the working file; the watcher owns + // its store writes. Checkout mutation/capture paths do write the view + // store and retain foreground preemption during their admission. + view := requestViewFromContext(ctx) + storeWriting := checkoutMutationFromContext(ctx) != nil || + (view.routed() && view.rider != nil && view.rider.Exact && view.viewRoot != "" && + view.files == nil && view.materialized != nil && s.lifecycle != nil) + if !storeWriting { + return ctx, func() {} + } + switch tool { + case "edit_file", "write_file", "edit_symbol", "batch_edit": + default: + return ctx, func() {} + } intent := &sourceMutationWriteIntent{announce: s.materializer.Store.AnnounceWrite} intent.release = intent.announce() return context.WithValue(ctx, sourceMutationWriteIntentKey{}, intent), intent.close diff --git a/internal/mcp/view_mutation_write_intent_test.go b/internal/mcp/view_mutation_write_intent_test.go index f3572e9db..9684f34f7 100644 --- a/internal/mcp/view_mutation_write_intent_test.go +++ b/internal/mcp/view_mutation_write_intent_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/graphview" ) func TestSourceMutationWriteIntentRestoresAdmissionAndCancelsWait(t *testing.T) { @@ -49,3 +50,19 @@ func TestSourceMutationWriteIntentRestoresAdmissionAndCancelsWait(t *testing.T) resume() require.False(t, store.WriteWanted(), "cleanup cannot be undone by a late wait return") } + +func TestSelectedCheckoutSourceMutationStillAnnouncesStoreIntent(t *testing.T) { + store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "checkout-intent.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + srv, root := setupTestServer(t) + srv.materializer = &graphview.Materializer{Store: store} + selected := withCheckoutMutation(t.Context(), &fakeCheckoutMutationLifecycle{}, root) + _, release := srv.withSourceMutationWriteIntent(selected, "edit_file") + require.True(t, store.WriteWanted(), "a selected store-writing checkout retains foreground preemption") + release() + require.False(t, store.WriteWanted()) + _, releaseRead := srv.withSourceMutationWriteIntent(selected, "get_symbol") + defer releaseRead() + require.False(t, store.WriteWanted(), "read requests never acquire a writer intent") +} diff --git a/internal/mcp/view_publication_binding_test.go b/internal/mcp/view_publication_binding_test.go index 7535615a0..18f4c23f2 100644 --- a/internal/mcp/view_publication_binding_test.go +++ b/internal/mcp/view_publication_binding_test.go @@ -66,7 +66,7 @@ func TestMutationPublicationRecordIsBoundBeforeAdmissionReturns(t *testing.T) { ctx, _ = withMutationCommitNote(ctx) ctx = withCheckoutMutation(ctx, mutation, filepath.Dir(path)) data := []byte("package committed\n") - record, err := s.commitFileMutation(ctx, "write_file", "", "", "edit.go", path, data, 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "edit.go", path, data, 0o600) require.NoError(t, err) sum := sha256.Sum256(data) inner.ticket.ContentHash = hex.EncodeToString(sum[:]) diff --git a/internal/mcp/view_request.go b/internal/mcp/view_request.go index 8b4d13a1f..fe3bab697 100644 --- a/internal/mcp/view_request.go +++ b/internal/mcp/view_request.go @@ -2597,10 +2597,7 @@ func viewRiderFields(view *requestView) map[string]any { fields["freshness_scope"] = view.sourceScope fields["checkout_incarnation"] = view.sourceCheckoutIncarnation fields["capability_scope"] = view.sourceScope - fields["graph_freshness"] = "unknown" - if view.sourceGraphPending { - fields["graph_freshness"] = "pending" - } + appendSourceFreshness(fields, view) if len(view.sourcePendingPaths) > 0 { fields["pending_file_count"] = len(view.sourcePendingPaths) fields["pending_files"] = view.sourcePendingPaths[:min(16, len(view.sourcePendingPaths))] diff --git a/internal/mcp/worktree_batch_integration_test.go b/internal/mcp/worktree_batch_integration_test.go index 0c998795e..593596fa1 100644 --- a/internal/mcp/worktree_batch_integration_test.go +++ b/internal/mcp/worktree_batch_integration_test.go @@ -165,7 +165,12 @@ func TestWorktreeBatchFacadeGuardedTwoFileEdit(t *testing.T) { require.Equal(t, "observed", ack.State) require.NotNil(t, ack.Snapshot) require.EqualValues(t, 3, ack.Snapshot.DiskWrite.Completed, "two physical files and one forward directory-sync envelope") - require.Equal(t, "not_applicable", ack.Snapshot.ParseGate.State) + require.Equal(t, "measured", ack.Snapshot.ParseGate.State, "the committed cohort now builds actual file projections") + require.EqualValues(t, 1, ack.Snapshot.ParseGate.Started, "parallel file parses share one actual ACK wall interval") + require.EqualValues(t, 1, ack.Snapshot.ParseGate.Completed) + require.NotNil(t, ack.Snapshot.ParseGate.ObservedMS) + require.GreaterOrEqual(t, *ack.Snapshot.ParseGate.ObservedMS, 0.0, "an actually measured interval may share one clock tick") + require.True(t, ack.Snapshot.LapsDisjoint) if root == f.worktree { require.EqualValues(t, 1, ack.Snapshot.TicketAdmission.Completed, "one actual checkout batch admission, not one per file") } diff --git a/internal/parser/crashpool/crashpool.go b/internal/parser/crashpool/crashpool.go index c7248ced2..df1466e6c 100644 --- a/internal/parser/crashpool/crashpool.go +++ b/internal/parser/crashpool/crashpool.go @@ -44,11 +44,12 @@ type extractRequest struct { // extractResponse is the worker → parent reply for one request. type extractResponse struct { - Seq uint64 - Nodes []*graph.Node - Edges []*graph.Edge - ParseErrors int - HasParseErr bool + Seq uint64 + Nodes []*graph.Node + Edges []*graph.Edge + ParseErrors int + ParseChecked bool + HasParseErr bool // Err is non-empty when the extractor returned an error or // panicked. Panicked distinguishes a recovered Go panic (the // worker survives) from a plain error return. @@ -58,10 +59,11 @@ type extractResponse struct { // Result is the parent-side outcome of one Submit call. type Result struct { - Nodes []*graph.Node - Edges []*graph.Edge - ParseErrors int - HasParseErr bool + Nodes []*graph.Node + Edges []*graph.Edge + ParseErrors int + ParseChecked bool + HasParseErr bool // Crashed is true when the worker subprocess died or hung // (SIGSEGV / OOM / kill / deadline). The file should be // quarantined and skipped. diff --git a/internal/parser/crashpool/pool.go b/internal/parser/crashpool/pool.go index 6d293ad5d..db88e109d 100644 --- a/internal/parser/crashpool/pool.go +++ b/internal/parser/crashpool/pool.go @@ -215,11 +215,12 @@ func (p *Pool) SubmitWithOptions(relPath, language string, content []byte, tempo return Result{Panicked: true, Err: resp.Err} } return Result{ - Nodes: resp.Nodes, - Edges: resp.Edges, - ParseErrors: resp.ParseErrors, - HasParseErr: resp.HasParseErr, - Err: resp.Err, + Nodes: resp.Nodes, + Edges: resp.Edges, + ParseErrors: resp.ParseErrors, + ParseChecked: resp.Err == "" && resp.ParseChecked && resp.ParseErrors >= 0, + HasParseErr: resp.HasParseErr, + Err: resp.Err, } case <-timer.C: // Worker hung. Killing it unblocks the roundTrip goroutine diff --git a/internal/parser/crashpool/pool_test.go b/internal/parser/crashpool/pool_test.go index f02b4680d..b940e0aa4 100644 --- a/internal/parser/crashpool/pool_test.go +++ b/internal/parser/crashpool/pool_test.go @@ -1,6 +1,7 @@ package crashpool import ( + "bytes" "encoding/gob" "fmt" "os" @@ -187,6 +188,7 @@ func TestPool_CrashIsolation(t *testing.T) { bad := p.Submit("dir/BOOM.mock", "mock", []byte("x")) require.True(t, bad.Crashed) + require.False(t, bad.ParseChecked) require.True(t, bad.Bad()) require.Contains(t, bad.Err, "crashed") @@ -222,6 +224,7 @@ func TestPool_RequestTimeout(t *testing.T) { p := newTestPool(t, "slow", Config{Workers: 1, RequestTimeout: 250 * time.Millisecond}) res := p.Submit("slow.mock", "mock", []byte("x")) require.True(t, res.Crashed) + require.False(t, res.ParseChecked) require.Contains(t, res.Err, "timed out") } @@ -266,3 +269,38 @@ func TestPool_RoutesWorkerStderrThroughLogger(t *testing.T) { require.Contains(t, lines, "worker stderr line 1") require.Contains(t, lines, "worker stderr line 2") } + +// Old workers and plugin extractors have no full-parse proof; the additive +// wire field must remain unknown instead of certifying their zero default. +func TestParseVerdictOldWorkerAndPluginRemainUnknown(t *testing.T) { + var wire bytes.Buffer + old := struct { + Seq uint64 + ParseErrors int + HasParseErr bool + }{Seq: 9} + require.NoError(t, gob.NewEncoder(&wire).Encode(old)) + var response extractResponse + require.NoError(t, gob.NewDecoder(&wire).Decode(&response)) + require.False(t, response.ParseChecked) + require.Zero(t, response.ParseErrors) + require.False(t, serveOne(mockRegistry(), extractRequest{Language: "mock", RelPath: "x.mock"}).ParseChecked) + require.False(t, serveOne(mockRegistry(), extractRequest{Language: "mockpanic", RelPath: "x.mp"}).ParseChecked) + require.False(t, serveOne(mockRegistry(), extractRequest{Language: "unknown"}).ParseChecked) +} + +func TestPoolNativeSyntaxVerdictRoundTrip(t *testing.T) { + p := newTestPool(t, "real", Config{Workers: 1}) + for _, source := range []string{"package p\nfunc Good() {}\n", "@@@ <<<<<\n"} { + result := p.Submit("main.go", "go", []byte(source)) + require.False(t, result.Bad(), result.Err) + require.True(t, result.ParseChecked) + if source[0] == '@' { + require.Positive(t, result.ParseErrors) + require.True(t, result.HasParseErr) + } else { + require.Zero(t, result.ParseErrors) + require.False(t, result.HasParseErr) + } + } +} diff --git a/internal/parser/crashpool/worker.go b/internal/parser/crashpool/worker.go index 58bf4c819..f26cb9094 100644 --- a/internal/parser/crashpool/worker.go +++ b/internal/parser/crashpool/worker.go @@ -115,6 +115,7 @@ func serveOne(reg *parser.Registry, req extractRequest) (resp extractResponse) { resp.Edges = nil resp.Err = fmt.Sprintf("extractor panic: %v\n%s", rec, debug.Stack()) resp.Panicked = true + resp.ParseChecked, resp.ParseErrors = false, 0 } }() @@ -132,13 +133,15 @@ func serveOne(reg *parser.Registry, req extractRequest) (resp extractResponse) { if result != nil { resp.Nodes = result.Nodes resp.Edges = result.Edges - if result.Tree != nil { - if result.Tree.HasParseErrors() { - resp.HasParseErr = true - resp.ParseErrors = result.Tree.CountParseErrors() - } - result.Tree.Release() + if result.ParseChecked && result.ParseErrors >= 0 { + resp.ParseChecked, resp.ParseErrors = true, result.ParseErrors + resp.HasParseErr = result.ParseErrors > 0 + } else if result.Tree != nil && result.Tree.HasParseErrors() { + // Legacy/plugin trees retain their old error metadata, but absence + // of an explicit full-parse proof cannot become checked zero. + resp.HasParseErr, resp.ParseErrors = true, result.Tree.CountParseErrors() } + result.ReleaseTree() } return resp } diff --git a/internal/parser/extractor.go b/internal/parser/extractor.go index 67d816c84..6b8143ad7 100644 --- a/internal/parser/extractor.go +++ b/internal/parser/extractor.go @@ -171,9 +171,14 @@ func AssetClassOf(e Extractor) AssetClass { // own trees internally — the contract pipeline degrades to its regex // fallback for those languages. type ExtractionResult struct { - Nodes []*graph.Node - Edges []*graph.Edge - Tree *ParseTree + // ParseChecked is a successful full native parse's explicit syntax + // verdict. A zero error count without this bit is unknown, never clean. + // Producers set both scalars before releasing their native tree. + ParseChecked bool + ParseErrors int + Nodes []*graph.Node + Edges []*graph.Edge + Tree *ParseTree // ConstValues carries the literal value of each KindConstant node // whose RHS is a string / numeric literal, for the indexer to persist // in the queryable constant_values sidecar (kept out of the gob Meta diff --git a/internal/parser/languages/c.go b/internal/parser/languages/c.go index d540dfed9..6a0ad1aaf 100644 --- a/internal/parser/languages/c.go +++ b/internal/parser/languages/c.go @@ -126,7 +126,8 @@ func (e *CExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRes defer tree.Close() root := tree.RootNode() - result := &parser.ExtractionResult{} + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index 338891db3..cc9da7d58 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -375,9 +375,9 @@ func (e *GoExtractor) ExtractWithOptions(filePath string, src []byte, opts parse // don't defer Close — closing happens in the indexer after the // contract pass releases its ref. pt := parser.NewParseTree(tree, src, "go") - result := &parser.ExtractionResult{Tree: pt} - root := tree.RootNode() + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{Tree: pt, ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, @@ -447,10 +447,14 @@ func (e *GoExtractor) ExtractWithOptions(filePath string, src []byte, opts parse // No-op (the package name is not currently surfaced as a node). case m.Captures["func.def"] != nil: - e.emitFunction(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports, seenIDs) + root.WithScratch(func() { + e.emitFunction(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports, seenIDs) + }) case m.Captures["method.def"] != nil: - e.emitMethod(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports, seenIDs) + root.WithScratch(func() { + e.emitMethod(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports, seenIDs) + }) case m.Captures["typedef.def"] != nil: e.emitTypeDecl(m, filePath, fileID, src, result, seenTypeName) @@ -1278,14 +1282,14 @@ func (e *GoExtractor) ExtractWithOptions(filePath string, src []byte, opts parse // override method emits as a separate KindMethod node, so this // post-pass runs after every node in the file has been emitted and // rewires any convention-derived EdgeModelsTable to the literal. - rewireORMTableNameOverrides(root, src, result) + root.WithScratch(func() { rewireORMTableNameOverrides(root, src, result) }) // WebSocket upgrade handlers → real-time endpoint edges. emitGoWebSocketEdges(src, filePath, result) // Same-file constant/variable value references → impact-radius reads. - captureValueRefCandidates(result, root, filePath, src) - captureFnValueCandidates(result, root, filePath, src) + root.WithScratch(func() { captureValueRefCandidates(result, root, filePath, src) }) + root.WithScratch(func() { captureFnValueCandidates(result, root, filePath, src) }) // Gin middleware-chain dispatcher + route registrations → resolver hints. mineGinMiddleware(src, result) @@ -1294,10 +1298,10 @@ func (e *GoExtractor) ExtractWithOptions(filePath string, src []byte, opts parse // map key/value, channel element. These positions are dropped by the // bare-name type passes, so without this find_usages of a type misses // every place it appears only as an argument/element type. - emitGoTypeArgReferences(root, src, filePath, fileID, funcRanges, result) + root.WithScratch(func() { emitGoTypeArgReferences(root, src, filePath, fileID, funcRanges, result) }) // GoFrame reflective routes: g.Meta-tagged request structs → controllers. - captureGoFrameRoutes(result, root, filePath, src) + root.WithScratch(func() { captureGoFrameRoutes(result, root, filePath, src) }) return result, nil } diff --git a/internal/parser/languages/java.go b/internal/parser/languages/java.go index 382058721..cbb0d4ab7 100644 --- a/internal/parser/languages/java.go +++ b/internal/parser/languages/java.go @@ -184,7 +184,8 @@ func (e *JavaExtractor) Extract(filePath string, src []byte) (*parser.Extraction defer tree.Close() root := tree.RootNode() - result := &parser.ExtractionResult{} + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, diff --git a/internal/parser/languages/parse_health_test.go b/internal/parser/languages/parse_health_test.go new file mode 100644 index 000000000..4bada88ec --- /dev/null +++ b/internal/parser/languages/parse_health_test.go @@ -0,0 +1,68 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/astquery" + "github.com/zzet/gortex/internal/parser" + sitter "github.com/zzet/gortex/internal/parser/tsitter" +) + +func TestNativeExtractionCarriesCheckedSyntaxVerdict(t *testing.T) { + for _, test := range []struct { + language, path, clean string + extractor parser.Extractor + }{ + {"go", "source.go", "package p\nfunc Good() {}\n", NewGoExtractor()}, + {"python", "source.py", "def good():\n return 1\n", NewPythonExtractor()}, + {"typescript", "source.ts", "export function good(): number { return 1; }\n", NewTypeScriptExtractor()}, + {"c", "source.c", "int good(void) { return 1; }\n", NewCExtractor()}, + {"java", "P.java", "class P { int good() { return 1; } }\n", NewJavaExtractor()}, + {"rust", "source.rs", "fn good() -> i32 { 1 }\n", NewRustExtractor()}, + } { + for _, source := range []string{test.clean, "@@@ <<<<<\n"} { + t.Run(test.language+map[bool]string{true: "/clean", false: "/errors"}[source == test.clean], func(t *testing.T) { + raw := []byte(source) + tree, err := parser.ParseFile(raw, astquery.DefaultLanguageResolver(test.language)) + require.NoError(t, err) + proof := parser.NewParseTree(tree, raw, test.language) + defer proof.Release() + count := proof.CountParseErrors() + if source == test.clean { + require.Zero(t, count) + } else { + require.Positive(t, count) + } + result, err := test.extractor.Extract(test.path, raw) + require.NoError(t, err) + defer result.ReleaseTree() + require.True(t, result.ParseChecked, "unknown default cannot certify a clean projection") + require.Equal(t, count, result.ParseErrors, "ERROR/MISSING count must match the original syntax gate") + }) + } + } +} + +func TestPinnedGrammarHasErrorIncludesMissingNode(t *testing.T) { + src := []byte("int good(void) { return 1 }\n") + tree, err := parser.ParseFile(src, astquery.DefaultLanguageResolver("c")) + require.NoError(t, err) + defer tree.Close() + root := tree.RootNode() + missing := false + var visit func(*sitter.Node) + visit = func(node *sitter.Node) { + if node == nil { + return + } + missing = missing || node.IsMissing() + for i := 0; i < int(node.ChildCount()); i++ { + visit(node.Child(i)) + } + } + visit(root) + require.True(t, missing, "fixture must contain a real pinned grammar MISSING node") + require.True(t, root.HasError(), "the clean fast path may skip walks only when HasError is false") +} diff --git a/internal/parser/languages/python.go b/internal/parser/languages/python.go index 4926d8b37..eb66b20d2 100644 --- a/internal/parser/languages/python.go +++ b/internal/parser/languages/python.go @@ -154,7 +154,8 @@ func (e *PythonExtractor) Extract(filePath string, src []byte) (*parser.Extracti defer tree.Close() root := tree.RootNode() - result := &parser.ExtractionResult{} + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, diff --git a/internal/parser/languages/rust.go b/internal/parser/languages/rust.go index 245028695..c1b6ddacb 100644 --- a/internal/parser/languages/rust.go +++ b/internal/parser/languages/rust.go @@ -177,7 +177,8 @@ func (e *RustExtractor) Extract(filePath string, src []byte) (*parser.Extraction defer tree.Close() root := tree.RootNode() - result := &parser.ExtractionResult{} + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, diff --git a/internal/parser/languages/typescript.go b/internal/parser/languages/typescript.go index b2f7878e4..aa9405e0b 100644 --- a/internal/parser/languages/typescript.go +++ b/internal/parser/languages/typescript.go @@ -186,7 +186,8 @@ func (e *TypeScriptExtractor) Extract(filePath string, src []byte) (*parser.Extr defer tree.Close() root := tree.RootNode() - result := &parser.ExtractionResult{} + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, diff --git a/internal/parser/parsetree.go b/internal/parser/parsetree.go index 25d41c16f..7065ab1b4 100644 --- a/internal/parser/parsetree.go +++ b/internal/parser/parsetree.go @@ -140,11 +140,24 @@ func (pt *ParseTree) CountParseErrors() int { if root == nil { return 0 } - count := 0 - walkParseErrors(root, &count) + _, count := ParseErrorVerdict(root) return count } +// ParseErrorVerdict captures the syntax verdict of a successful full parse +// while its root is live. The clean path is one native HasError query; only +// error trees are walked. A missing root is unknown rather than checked zero. +func ParseErrorVerdict(root *sitter.Node) (checked bool, count int) { + if root == nil { + return false, 0 + } + if !root.HasError() { + return true, 0 + } + walkParseErrors(root, &count) + return true, count +} + func walkParseErrors(n *sitter.Node, count *int) { if n == nil { return diff --git a/internal/parser/treesitter.go b/internal/parser/treesitter.go index 81112ace6..fdc45b576 100644 --- a/internal/parser/treesitter.go +++ b/internal/parser/treesitter.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "sync" + "sync/atomic" "time" ts "github.com/tree-sitter/go-tree-sitter" @@ -106,6 +107,14 @@ func LooksBinary(src []byte) bool { return bytes.IndexByte(src, 0) >= 0 } +// parseCount counts the sources ParseFile handed to tree-sitter. +var parseCount atomic.Uint64 + +// ParseCount reports how many sources ParseFile has handed to tree-sitter +// since the process started. Callers compare two readings to pin how many +// parses a code path costs. +func ParseCount() uint64 { return parseCount.Load() } + // ParseFile parses source bytes with the given language and returns the tree. // The caller must call tree.Close() when done. func ParseFile(src []byte, lang *sitter.Language) (*sitter.Tree, error) { @@ -144,6 +153,7 @@ func ParseFile(src []byte, lang *sitter.Language) (*sitter.Tree, error) { ctx, cancel := context.WithTimeout(context.Background(), parseTimeout) defer cancel() + parseCount.Add(1) tree, err := parser.ParseCtx(ctx, nil, src) if err != nil { return nil, fmt.Errorf("tree-sitter parse: %w", err) diff --git a/internal/pathkey/mutation_key.go b/internal/pathkey/mutation_key.go new file mode 100644 index 000000000..a26e29540 --- /dev/null +++ b/internal/pathkey/mutation_key.go @@ -0,0 +1,28 @@ +package pathkey + +import "path/filepath" + +// MutationKey returns the one identity under which every writer and reader of +// a source file agrees on that file: the mutation path lock, the pending +// projection of the bytes a tool just wrote, and the watcher events that +// follow the write. +// +// A tool request names a file in the caller's spelling (/tmp/repo/a.go, a +// symlinked workspace, a case variant on a case-insensitive volume) while the +// watcher reports the root's spelling (/private/tmp/repo/a.go). A lexical key +// gives each spelling its own lock, so two edits of one file can interleave +// and one of them is lost. MutationKey resolves symlinks in the parent +// directory, keeping a missing suffix as written, then folds the result like +// IdentityKey. The leaf itself is never followed: writing a symlinked file +// replaces the link, so the link and its target are distinct files. +func MutationKey(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + return IdentityKey(path) + } + dir, leaf := filepath.Split(filepath.Clean(abs)) + if leaf == "" { + return IdentityKey(CanonicalPath(abs)) + } + return IdentityKey(filepath.Join(CanonicalPath(dir), leaf)) +} diff --git a/internal/pathkey/mutation_key_test.go b/internal/pathkey/mutation_key_test.go new file mode 100644 index 000000000..b99ae6dae --- /dev/null +++ b/internal/pathkey/mutation_key_test.go @@ -0,0 +1,67 @@ +package pathkey + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestMutationKeyJoinsAliasSpellings(t *testing.T) { + base := t.TempDir() + realDir := filepath.Join(base, "real") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(realDir, "a.go"), []byte("package a\n"), 0o644); err != nil { + t.Fatal(err) + } + aliasDir := filepath.Join(base, "alias") + if err := os.Symlink(realDir, aliasDir); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + same := []struct { + name string + a, b string + }{ + {name: "symlinked directory", a: filepath.Join(realDir, "a.go"), b: filepath.Join(aliasDir, "a.go")}, + {name: "file not created yet", a: filepath.Join(realDir, "new.go"), b: filepath.Join(aliasDir, "new.go")}, + {name: "dot segments", a: filepath.Join(realDir, "a.go"), b: realDir + string(filepath.Separator) + "." + string(filepath.Separator) + "a.go"}, + } + if runtime.GOOS == "darwin" { + same = append(same, struct { + name string + a, b string + }{name: "tmp alias", a: "/tmp/gortex-mutation-key/a.go", b: "/private/tmp/gortex-mutation-key/a.go"}) + } + for _, tc := range same { + t.Run(tc.name, func(t *testing.T) { + if ka, kb := MutationKey(tc.a), MutationKey(tc.b); ka != kb { + t.Fatalf("MutationKey(%q) = %q, MutationKey(%q) = %q; want one key", tc.a, ka, tc.b, kb) + } + }) + } + + t.Run("case variant on a case-insensitive volume", func(t *testing.T) { + prev := CaseInsensitivePaths + CaseInsensitivePaths = true + t.Cleanup(func() { CaseInsensitivePaths = prev }) + a := filepath.Join(realDir, "a.go") + for _, b := range []string{filepath.Join(realDir, "A.go"), filepath.Join(base, "REAL", "a.go"), filepath.Join(aliasDir, "A.GO")} { + if ka, kb := MutationKey(a), MutationKey(b); ka != kb { + t.Fatalf("MutationKey(%q) = %q, MutationKey(%q) = %q; want one key", a, ka, b, kb) + } + } + }) + + t.Run("leaf symlink is its own file", func(t *testing.T) { + link := filepath.Join(realDir, "link.go") + if err := os.Symlink(filepath.Join(realDir, "a.go"), link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if MutationKey(link) == MutationKey(filepath.Join(realDir, "a.go")) { + t.Fatalf("MutationKey followed the leaf symlink %q", link) + } + }) +} diff --git a/internal/profiles/bodies.go b/internal/profiles/bodies.go index 6dac1388d..1fae0314c 100644 --- a/internal/profiles/bodies.go +++ b/internal/profiles/bodies.go @@ -70,26 +70,11 @@ var sectionCompactMemory = bt(`Use §recall§ before revisiting prior work; §re // this constant rather than paraphrase it; routing is safety policy, and two // almost-equivalent copies eventually teach agents different destructive // actions. -const WorktreeBranchRoutingPolicy = "- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph.\n" + - "- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy.\n" + - // The `require_fresh` clause is qualified because the wait only advances a - // routed automatic checkout: a shared corpus, a dedicated/primary checkout - // and a labelled base/ref/commit view all answer fresh:false with a - // fresh_reason rather than waiting (internal/mcp/checkout_binding.go's - // freshReasonCommittedBaseAdvance). "absolute" is dropped from the - // deadline clause because RFC3339 names an absolute timestamp by - // definition; the refusal message and the views guide both still say it in - // full. - // - // Paying for that qualification out of this bullet alone left the core - // body 3 bytes under its ceiling, which is a ceiling that cannot absorb - // the next correction. The elaboration around every section of the body - // was trimmed instead — never a rule, a tool name or a contract — so the - // ceilings in profiles_test.go keep real head-room and none of them is - // re-based upward. - "- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`.\n" + - "- Select explicitly when CWD is not the target: `view:{kind:\"worktree\",checkout_id:\"…\"}` or `view:{kind:\"git_ref\",value:\"refs/heads/release\",graph_id:\"…\"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not.\n" + - "- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation.\n" +const WorktreeBranchRoutingPolicy = "- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph.\n" + + "- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy.\n" + + "- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:\"relations\"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`.\n" + + "- Target another CWD: `view:{kind:\"worktree\",checkout_id:\"…\"}` or `view:{kind:\"git_ref\",value:\"refs/heads/release\",graph_id:\"…\"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not.\n" + + "- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation.\n" // sectionWorktreeViews teaches agents the ownership boundary that keeps // ordinary linked-worktree use cheap. It is shared by every profile so a lean diff --git a/internal/profiles/routing_freshness_policy_test.go b/internal/profiles/routing_freshness_policy_test.go index 6a753ccfa..7d7d3fbb1 100644 --- a/internal/profiles/routing_freshness_policy_test.go +++ b/internal/profiles/routing_freshness_policy_test.go @@ -38,7 +38,12 @@ func TestRoutingPolicyQualifiesTheFreshnessWait(t *testing.T) { // The qualification itself: what require_fresh waits for, and what the // rest of the view space gets instead. for _, required := range []string{ - "routed automatic checkouts only", + "watched primaries", + "routed automatic checkouts", + "ref/commit views", + "readback_exact:true", + "freshness_scope:\"relations\"", + "pending files are not dead", "fresh:false", } { if !strings.Contains(policy, required) { diff --git a/internal/query/engine.go b/internal/query/engine.go index 94838da47..2ba0fafa6 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -48,6 +48,7 @@ type Engine struct { // pre-materialised (the bundle fast path) predates the overlay and // has to be re-read through the reader before it can be trusted. overlay *graph.OverlayLayer + layers []graph.OverlayLayerReader // viewLayers are the persisted generations stacked in the reader, // bottom first, empty for a plain base reader. Each carries its own // text corpus, so candidate enumeration queries them alongside the @@ -135,6 +136,7 @@ func (e *Engine) WithReader(r graph.Reader) *Engine { clone := *e clone.g = r clone.overlay = nil + clone.layers = nil // A reader swap re-decides which corpora answer, so the stack the // previous reader named never carries over — WithViewLayers is the // only way one is bound. @@ -142,6 +144,9 @@ func (e *Engine) WithReader(r graph.Reader) *Engine { clone.viewExcludesBase = false if view, ok := r.(overlayLayered); ok { clone.overlay = view.Layer() + if clone.overlay != nil { + clone.layers = []graph.OverlayLayerReader{clone.overlay} + } } return &clone } @@ -943,7 +948,7 @@ func (e *Engine) gatherBackendCandidates(ctx context.Context, query string, limi bundleCtx = search.WithSymbolBundleTimingsObserver(ctx, timings.BundleLegs.Add) } var answer requestBundleAnswer - if len(opts.SearchPathPrefixes) > 0 && !viewLayered && e.overlay == nil { + if len(opts.SearchPathPrefixes) > 0 && !viewLayered && e.overlay == nil && len(e.layers) == 0 { answer = requestPathScopedSymbolBundles(bundleCtx, backend, query, repoAllowList(opts.RepoAllow), opts.SearchPathPrefixes, fetchLimit*2) } if allow := repoAllowList(opts.RepoAllow); !answer.authoritative && len(allow) > 0 { @@ -964,22 +969,14 @@ func (e *Engine) gatherBackendCandidates(ctx context.Context, query string, limi if timings != nil { timings.BundleMS += time.Since(bundleStart).Milliseconds() } - // A per-request overlay reader invalidates the bundle payload: - // the backend indexes the base graph, so a symbol the buffer - // replaced arrives carrying its pre-edit node, and a symbol the - // buffer deleted arrives at all. Keep the BM25 ranking — the - // scores are still the corpus's answer — and drop the payload: - // every hit falls through to the view-aware batched lookup - // below, which substitutes the overlay's node and omits the IDs - // the view no longer exposes. - // - // A composed view invalidates it for the same reason one level - // down: the bundle's payload is the indexed corpus's row, and a - // generation in the stack may have replaced or deleted it. - rehydrateThroughReader := e.overlay != nil || viewLayered + // Ownership is explicit even when the runtime wraps the view reader. + // Keep each safe base payload and adjacency; only affected hits fall back + // to the composed reader. Partial adjacency seeds remain partial. + if len(bundles) > 0 { bundleHandled = true textResults = make([]search.SearchResult, 0, len(bundles)) + allEdgesSeeded := true outSeed := make(map[string][]*graph.Edge, len(bundles)) inSeed := make(map[string][]*graph.Edge, len(bundles)) for _, b := range bundles { @@ -987,31 +984,20 @@ func (e *Engine) gatherBackendCandidates(ctx context.Context, query string, limi continue } textResults = append(textResults, search.SearchResult{ID: b.Node.ID, Score: b.Score}) - if rehydrateThroughReader { + if !e.bundleNodeNeedsReader(b.Node) { + bundleNodeByID[b.Node.ID] = b.Node + } + if e.bundleEdgesNeedReader(b) { + allEdgesSeeded = false continue } - bundleNodeByID[b.Node.ID] = b.Node outSeed[b.Node.ID] = b.OutEdges inSeed[b.Node.ID] = b.InEdges } - // Seed the rerank context's edge caches so prepare() can - // skip its own batched fetch for the bundle-covered IDs. - // preSeeded=true is the contract that prepare's batched - // edge fetch is now redundant — see rerank.Context for the - // invariant the engine relies on (the next caller's - // candidate set is fully covered by these maps for the - // BM25 hits; vector / substring fallback hits are still - // served by the per-candidate accessor fallback). - // - // Under an overlay the seed is skipped entirely rather than - // filtered down to the uncovered files: the layer also - // *introduces* edges no base bundle carries, so no subset of - // base edges can honour the pre-seed contract. Dropping it - // hands the fetch back to the rerank pass, which reads - // through the same overlay-aware reader. - if rctx != nil && !rehydrateThroughReader { - rctx.SeedEdgeCaches(inSeed, outSeed, true) + if rctx != nil && len(outSeed) > 0 { + rctx.SeedEdgeCaches(inSeed, outSeed, allEdgesSeeded) } + } else if scopedAnswered { // A scoped query that answered zero IS the answer. Leaving // bundleHandled false here would send the miss down the diff --git a/internal/query/engine_layer_bundle_test.go b/internal/query/engine_layer_bundle_test.go new file mode 100644 index 000000000..ca0bed663 --- /dev/null +++ b/internal/query/engine_layer_bundle_test.go @@ -0,0 +1,106 @@ +package query + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/search" + "github.com/zzet/gortex/internal/search/rerank" +) + +type bundleHydrationReader struct { + graph.Reader + layer *graph.OverlayLayer + hydrated []string +} + +func (r *bundleHydrationReader) Layer() *graph.OverlayLayer { return r.layer } +func (r *bundleHydrationReader) GetNodesByIDs(ids []string) map[string]*graph.Node { + r.hydrated = append(r.hydrated, ids...) + return r.Reader.GetNodesByIDs(ids) +} + +func TestLayerBundleHydrationKeepsUntouchedPayloadAndEdges(t *testing.T) { + base, backend, layer, replacement := overlayBundleFixture(t) + untouched := &graph.Node{ID: "repo/untouched.go::WidgetOther", Name: "WidgetOther", Kind: graph.KindFunction, FilePath: "repo/untouched.go"} + base.AddNode(untouched) + backend.bundles = append(backend.bundles, search.SymbolBundle{Node: untouched, Score: 8}) + reader := &bundleHydrationReader{Reader: graph.NewOverlaidView(base, layer), layer: layer} + engine := NewEngine(base) + engine.SetSearch(backend) + rctx := &rerank.Context{Graph: reader} + got := engine.WithReader(reader).GatherSymbolCandidates("Widget", 5, QueryOptions{SkipInnerRerank: true, SkipVectorChannel: true}, rctx) + require.Same(t, replacement, candidateByID(got, overlayBundleKeptID).Node) + require.Nil(t, candidateByID(got, overlayBundleGoneID)) + require.Same(t, untouched, candidateByID(got, untouched.ID).Node) + require.False(t, slices.Contains(reader.hydrated, untouched.ID), "an unrelated pending file must not discard this base bundle payload") + require.Equal(t, 1.0, rctx.EdgeCacheHitRate([]string{untouched.ID}), "safe base adjacency must remain seeded even when another hit needs rehydration") + require.Equal(t, 0.0, rctx.EdgeCacheHitRate([]string{overlayBundleKeptID}), "covered adjacency cannot be seeded from a stale base bundle") + require.False(t, rctx.CachePreSeeded(), "partial seeds cannot claim complete coverage") +} + +// The runtime contract wrapper deliberately hides the concrete Layer method. +// The explicit layer input must survive that wrapper, without type guessing. +type bundleContractReader struct{ graph.Reader } + +func TestLayerBundleContractWrapperKeepsPendingReplacement(t *testing.T) { + base, backend, layer, replacement := overlayBundleFixture(t) + reader := bundleContractReader{Reader: graph.NewOverlaidView(base, layer)} + engine := NewEngine(base) + engine.SetSearch(backend) + bound := engine.WithReader(reader) + if explicit, ok := any(bound).(interface { + WithLayers([]graph.OverlayLayerReader) *Engine + }); ok { + layers := []graph.OverlayLayerReader{layer} + bound = explicit.WithLayers(layers) + layers[0] = graph.NewOverlayLayer() // Caller slice mutation cannot rewrite an already bound request. + } + got := bound.GatherSymbolCandidates("Widget", 5, QueryOptions{SkipInnerRerank: true, SkipVectorChannel: true}, &rerank.Context{Graph: reader}) + require.Nil(t, candidateByID(got, overlayBundleGoneID), "contract wrapping must not revive a deleted base hit") + kept := candidateByID(got, overlayBundleKeptID) + require.NotNil(t, kept) + require.Same(t, replacement, kept.Node, "contract wrapping must not serve the pre-edit bundle row") +} + +func TestLayerBundleDoesNotSeedAdjacencyAffectedThroughAnotherFile(t *testing.T) { + for _, kind := range []string{"new_edge", "deleted_endpoint", "changed_edge_owner"} { + t.Run(kind, func(t *testing.T) { + base, backend, layer, _ := overlayBundleFixture(t) + node := &graph.Node{ID: "repo/untouched.go::WidgetOther", Name: "WidgetOther", Kind: graph.KindFunction, FilePath: "repo/untouched.go"} + base.AddNode(node) + bundle := search.SymbolBundle{Node: node, Score: 8} + if kind == "new_edge" { + layer.AddEdge(&graph.Edge{From: overlayBundleKeptID, To: node.ID, Kind: graph.EdgeCalls, FilePath: overlayBundleFile}) + } else { + target, file := overlayBundleGoneID, node.FilePath + if kind == "changed_edge_owner" { + target = overlayBundleCaller + file = overlayBundleFile + } + edge := &graph.Edge{From: node.ID, To: target, Kind: graph.EdgeCalls, FilePath: file} + base.AddEdge(edge) + bundle.OutEdges = []*graph.Edge{edge} + } + backend.bundles = append(backend.bundles, bundle) + view := graph.NewOverlaidView(base, layer) + reader := &bundleHydrationReader{Reader: view, layer: layer} + engine := NewEngine(base) + engine.SetSearch(backend) + rctx := &rerank.Context{Graph: reader} + got := engine.WithReader(reader).GatherSymbolCandidates("Widget", 5, QueryOptions{SkipInnerRerank: true, SkipVectorChannel: true}, rctx) + kept := candidateByID(got, node.ID) + require.NotNil(t, kept) + require.Same(t, node, kept.Node, "adjacency changes do not require refetching an unchanged node") + require.False(t, slices.Contains(reader.hydrated, node.ID)) + require.Zero(t, rctx.EdgeCacheHitRate([]string{node.ID}), "new/deleted/re-owned edges cannot be seeded from base bundles") + if kind == "new_edge" { + require.Len(t, view.GetInEdges(node.ID), 1) + } else { + require.Empty(t, view.GetOutEdges(node.ID)) + } + }) + } +} diff --git a/internal/query/engine_layers.go b/internal/query/engine_layers.go new file mode 100644 index 000000000..c99f24bfa --- /dev/null +++ b/internal/query/engine_layers.go @@ -0,0 +1,57 @@ +package query + +import ( + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/search" +) + +// WithLayers binds the ownership claims of the reader already selected on +// this clone. Reader wrappers need not expose concrete overlay implementations. +// The slice is copied; callers retain ownership of their slice storage. +func (e *Engine) WithLayers(layers []graph.OverlayLayerReader) *Engine { + if e == nil { + return nil + } + clone := *e + clone.layers = append([]graph.OverlayLayerReader(nil), layers...) + return &clone +} + +func (e *Engine) bundleNodeNeedsReader(node *graph.Node) bool { + if e.viewLayersActive() && len(e.layers) == 0 { + return true + } + for _, layer := range e.layers { + if layer != nil && (layer.HasFile(node.FilePath) || layer.CoversNodeID(node.ID) || layer.OwnsNodeIdentity(node.ID)) { + return true + } + } + return false +} + +func (e *Engine) bundleEdgesNeedReader(bundle search.SymbolBundle) bool { + node := bundle.Node + if e.bundleNodeNeedsReader(node) { + return true + } + for _, layer := range e.layers { + if layer == nil { + continue + } + // Base bundles cannot carry adjacency introduced by an upper layer. + if layer.OwnsOutEdges(node.ID) || len(layer.OutEdges(node.ID)) > 0 || len(layer.InEdges(node.ID)) > 0 { + return true + } + for _, edges := range [][]*graph.Edge{bundle.InEdges, bundle.OutEdges} { + for _, edge := range edges { + if edge == nil { + continue + } + if layer.HasFile(edge.FilePath) || layer.CoversNodeID(edge.From) || layer.CoversNodeID(edge.To) || layer.OwnsNodeIdentity(edge.From) || layer.OwnsNodeIdentity(edge.To) || layer.OwnsOutEdges(edge.From) { + return true + } + } + } + } + return false +} diff --git a/internal/query/view_candidates.go b/internal/query/view_candidates.go index 48f041603..5c163d58f 100644 --- a/internal/query/view_candidates.go +++ b/internal/query/view_candidates.go @@ -43,6 +43,11 @@ func (e *Engine) WithViewLayers(r graph.Reader, layers []ViewLayerSource) *Engin return clone } clone.viewLayers = layers + for _, source := range layers { + if source.Layer != nil { + clone.layers = append(clone.layers, source.Layer) + } + } return clone } diff --git a/internal/reach/coordination_wait.go b/internal/reach/coordination_wait.go new file mode 100644 index 000000000..fa75936b9 --- /dev/null +++ b/internal/reach/coordination_wait.go @@ -0,0 +1,56 @@ +package reach + +import ( + "context" + "sync" + "time" +) + +// CoordinationWait describes one actual lookup admission, separately from +// traversal time. Detached readers report Attempted=false for the mutex. +type CoordinationWait struct { + Phase string + Duration time.Duration + Attempted bool + Acquired bool +} + +type coordinationWaitKey struct{} + +// WithCoordinationWaitObserver observes only lookups descended from ctx; it +// retains no process-wide history and changes no gate or locking policy. +func WithCoordinationWaitObserver(ctx context.Context, observer func(CoordinationWait)) context.Context { + if observer == nil { + return ctx + } + return context.WithValue(ctx, coordinationWaitKey{}, observer) +} + +func coordinationWaitObserver(ctx context.Context) func(CoordinationWait) { + observer, _ := ctx.Value(coordinationWaitKey{}).(func(CoordinationWait)) + return observer +} + +func observedTopologyRead(ctx context.Context) (func(), bool) { + observer := coordinationWaitObserver(ctx) + if observer == nil { + return beginTopologyReadContext(ctx) + } + started := time.Now() + release, ok := beginTopologyReadContext(ctx) + observer(CoordinationWait{Phase: "gate_wait", Duration: time.Since(started), Attempted: true, Acquired: ok}) + return release, ok +} + +// observedResolveLock reports the existing mutex admission without changing +// which readers acquire it or the cancellation policy. +func observedResolveLock(ctx context.Context, mu *sync.Mutex) bool { + observer := coordinationWaitObserver(ctx) + if observer == nil { + return lockContext(ctx, mu) + } + started := time.Now() + ok := lockContext(ctx, mu) + observer(CoordinationWait{Phase: "resolve_mutex_wait", Duration: time.Since(started), Attempted: true, Acquired: ok}) + return ok +} diff --git a/internal/reach/coordination_wait_test.go b/internal/reach/coordination_wait_test.go new file mode 100644 index 000000000..70953b8c3 --- /dev/null +++ b/internal/reach/coordination_wait_test.go @@ -0,0 +1,87 @@ +package reach + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestLookupCoordinationWaitKeepsGateAndMutexSeparate(t *testing.T) { + for _, held := range []string{"gate_wait", "resolve_mutex_wait"} { + t.Run(held, func(t *testing.T) { + g, ids := newCallChain(t, 3) + var release func() + if held == "gate_wait" { + finish := BeginTopologyMutation(g) + release = func() { finish(false) } + } else { + mu := g.ResolveMutex() + mu.Lock() + release = mu.Unlock + } + var once sync.Once + unblock := func() { once.Do(release) } + t.Cleanup(unblock) + observed := make(chan CoordinationWait, 8) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + ctx = WithCoordinationWaitObserver(ctx, func(wait CoordinationWait) { observed <- wait }) + done := make(chan struct{}) + go func() { LookupContext(ctx, g, ids[2]); close(done) }() + timer := time.NewTimer(20 * time.Millisecond) + defer timer.Stop() + var events []CoordinationWait + waiting := true + for waiting { + select { + case wait := <-observed: + events = append(events, wait) + if wait.Phase == held { + t.Fatalf("%s reported acquired before its held lock released: %+v", held, wait) + } + case <-timer.C: + waiting = false + } + } + unblock() + select { + case <-done: + case <-ctx.Done(): + t.Fatal("lookup did not complete after its lock released") + } + for len(observed) > 0 { + events = append(events, <-observed) + } + found := false + for _, wait := range events { + if wait.Phase == held { + found = true + if !wait.Attempted || !wait.Acquired || wait.Duration <= 0 { + t.Fatalf("held wait was not actually measured: %+v", wait) + } + } + } + if !found { + t.Fatalf("missing %s observation: %+v", held, events) + } + }) + } +} + +func TestLookupCoordinationWaitReportsCancelledGlobalGate(t *testing.T) { + g, ids := newCallChain(t, 2) + finish := BeginTopologyMutation(g) + defer finish(false) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + var events []CoordinationWait + ctx = WithCoordinationWaitObserver(ctx, func(wait CoordinationWait) { events = append(events, wait) }) + _, _, _, _, truncated := LookupContext(ctx, g, ids[1]) + if !truncated { + t.Fatal("cancelled gate admission must retain its conservative result") + } + if len(events) != 1 || events[0].Phase != "gate_wait" || !events[0].Attempted || events[0].Acquired { + t.Fatalf("cancelled global gate claimed successful or mutex admission: %+v", events) + } +} diff --git a/internal/reach/global_admission_test.go b/internal/reach/global_admission_test.go new file mode 100644 index 000000000..16eeb298c --- /dev/null +++ b/internal/reach/global_admission_test.go @@ -0,0 +1,39 @@ +package reach + +import ( + "context" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +// Before repository-scoped admission is introduced, SQLite lookups use the +// same global gate and resolver mutex as the existing base-topology reader. +// Cancellation must report the actual failed mutex acquisition, not detached +// admission borrowed from the later repository-topology implementation. +func TestGlobalReachAdmissionRetainsSQLiteMutex(t *testing.T) { + store := openTopologyScopeStore(t) + store.AddNode(&graph.Node{ID: "seed", Name: "seed", Kind: graph.KindFunction}) + mu := store.ResolveMutex() + mu.Lock() + defer mu.Unlock() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + var waits []CoordinationWait + ctx = WithCoordinationWaitObserver(ctx, func(wait CoordinationWait) { + waits = append(waits, wait) + }) + _, _, _, _, truncated := LookupContext(ctx, store, "seed") + if !truncated { + t.Fatal("cancelled lookup must retain its conservative result") + } + for _, wait := range waits { + if wait.Phase == "resolve_mutex_wait" { + if !wait.Attempted || wait.Acquired { + t.Fatalf("held SQLite mutex must report a cancelled acquisition: %+v", wait) + } + return + } + } + t.Fatalf("actual SQLite mutex admission was not observed: %+v", waits) +} diff --git a/internal/reach/reach.go b/internal/reach/reach.go index c3956d1d3..7cd520545 100644 --- a/internal/reach/reach.go +++ b/internal/reach/reach.go @@ -847,12 +847,12 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed defer cancel() for { - releaseTopology, ok := beginTopologyReadContext(ctx) + releaseTopology, ok := observedTopologyRead(ctx) if !ok { return nil, nil, nil, false, true } mu := g.ResolveMutex() - if !lockContext(ctx, mu) { + if !observedResolveLock(ctx, mu) { releaseTopology() return nil, nil, nil, false, true } diff --git a/internal/serverstack/backend.go b/internal/serverstack/backend.go index c728b09bb..a67f9430e 100644 --- a/internal/serverstack/backend.go +++ b/internal/serverstack/backend.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "strings" + "time" "go.uber.org/zap" @@ -32,10 +33,11 @@ import ( // // Returns the store, a cleanup func the caller must defer (it closes the // handle on disk), and any open error. -// allowRebuild permits rebuilding supported older schemas when necessary; -// a schema newer than this binary is always refused and retained. The caller -// must hold the store lock. NewSharedServer passes true only after acquiring -// the exclusive flock. +// allowRebuild permits rebuilding supported older schemas when necessary and +// backing an existing store up before an in-place schema upgrade; a schema +// newer than this binary is always refused and retained. The caller must +// hold the store lock. NewSharedServer passes true only after acquiring the +// exclusive flock. func OpenBackend(name, path string, logger *zap.Logger, allowRebuild bool, observers ...store_sqlite.MigrationObserver) (graph.Store, func() error, error) { if err := checkBackend(name); err != nil { return nil, nil, err @@ -109,12 +111,14 @@ func openSqliteBackend(path string, allowRebuild bool, observers ...store_sqlite opts = append(opts, store_sqlite.WithMigrationObserver(observers[0])) } if allowRebuild { - opts = append(opts, store_sqlite.WithRebuild()) + // Both need exclusive access to the store, which allowRebuild attests. + opts = append(opts, store_sqlite.WithRebuild(), store_sqlite.WithUpgradeBackup()) } s, err := store_sqlite.Open(path, opts...) if err != nil { - if errors.Is(err, store_sqlite.ErrSchemaTooNew) { - // Restarting another process cannot make this binary compatible. + if errors.Is(err, store_sqlite.ErrSchemaTooNew) || errors.Is(err, store_sqlite.ErrInsufficientUpgradeSpace) { + // Restarting another process cannot make this binary compatible, + // nor free the disk space an upgrade needs. return nil, nil, fmt.Errorf("open sqlite store at %q: %w", path, err) } hint := "if another gortex daemon is using this store, stop it first (`gortex daemon status` / `gortex daemon stop`)" @@ -125,3 +129,18 @@ func openSqliteBackend(path string, allowRebuild bool, observers ...store_sqlite } return s, s.Close, nil } + +// pruneStoreUpgradeBackups removes the pre-upgrade backups of storePath that +// have outlived store_sqlite.UpgradeBackupRetention at now, along with any +// partial copy an interrupted upgrade left behind. The caller must hold the +// store lock. Failures are logged, never fatal: a backup that cannot be +// removed costs disk space, not correctness. +func pruneStoreUpgradeBackups(storePath string, logger *zap.Logger, now time.Time) { + removed, err := store_sqlite.PruneUpgradeBackups(storePath, store_sqlite.UpgradeBackupRetention, now) + for _, path := range removed { + logger.Info("serverstack: removed expired store upgrade backup", zap.String("path", path)) + } + if err != nil { + logger.Warn("serverstack: could not prune store upgrade backups", zap.String("store", storePath), zap.Error(err)) + } +} diff --git a/internal/serverstack/backend_upgrade_backup_test.go b/internal/serverstack/backend_upgrade_backup_test.go new file mode 100644 index 000000000..1ef453ea5 --- /dev/null +++ b/internal/serverstack/backend_upgrade_backup_test.go @@ -0,0 +1,58 @@ +package serverstack + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +func TestOpenBackendBacksUpAnUpgradeOnlyUnderTheStoreLock(t *testing.T) { + for name, lockHeld := range map[string]bool{"locked": true, "unlocked": false} { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "store.sqlite") + s, err := store_sqlite.Open(path) + if err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + // An index-only upgrade from v40 is pending on the next open. + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec("PRAGMA user_version=40"); err != nil { + _ = db.Close() + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + g, cleanup, err := OpenBackend("sqlite", path, zap.NewNop(), lockHeld) + if err != nil { + t.Fatalf("open older store: %v", err) + } + if g == nil { + t.Fatal("no store") + } + if err := cleanup(); err != nil { + t.Fatal(err) + } + + _, statErr := os.Stat(store_sqlite.UpgradeBackupPath(path, 40)) + if lockHeld && statErr != nil { + t.Fatalf("the lock holder's upgrade must leave a backup: %v", statErr) + } + if !lockHeld && !os.IsNotExist(statErr) { + t.Fatalf("an open without the store lock must not copy the store (stat err %v)", statErr) + } + }) + } +} diff --git a/internal/serverstack/shared_server.go b/internal/serverstack/shared_server.go index 0f69aad0a..fd134ef5a 100644 --- a/internal/serverstack/shared_server.go +++ b/internal/serverstack/shared_server.go @@ -422,6 +422,11 @@ func NewSharedServer(cfg SharedServerConfig) (*SharedServer, error) { } s.addCleanup("store_lock", lock.Unlock) storeLockHeld = true + // Expired pre-upgrade backups go before the store opens, so the space + // they free counts toward an upgrade's free-space check. Only under + // the lock: a partial copy can be in progress only while some process + // holds it. + pruneStoreUpgradeBackups(storePath, logger, time.Now()) } // allowRebuild is gated on actually holding the store lock: only then may @@ -855,6 +860,7 @@ func NewSharedServer(cfg SharedServerConfig) (*SharedServer, error) { } scopeIntentDefaults := conf.Scope.IntentDefaults multiOpts := []gortexmcp.MultiRepoOptions{{ + EditAckMode: conf.Edit.Ack, MultiIndexer: mi, ConfigManager: cm, CheckoutLifecycle: s.CheckoutLifecycle,