From 713809b388f88861a8d91f0a23c5487ea4dead9f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:17:19 +0100 Subject: [PATCH 01/61] graph: carry a re-parsed file's call edges as provisional An overlay that covers a file hides every stored edge recorded there, and the file's re-parse holds its calls as unresolved references until relations are rebuilt. Reading an edited file through an overlay would therefore drop the edited function's callees and remove it from the callers of everything it calls, although none of those calls changed. CarryBaseEdges returns copies of the stored call edges whose source still calls the same target name in the re-parse, at the new call's line and marked with the new Edge.Provisional flag. Repeated calls pair up in line order, and a call the edit removed is not carried. Provisional is never persisted and stays out of the edge identity, like NameOnly, so the wire-contract fingerprint and the delta writer's field count record it as an in-memory field. Refs #1014 --- cmd/gortex/wire_contract_test.go | 18 +++--- internal/graph/delta_writer_payload.go | 5 +- internal/graph/edge.go | 7 ++ internal/graph/overlay.go | 89 ++++++++++++++++++++++++++ internal/graph/overlay_carry_test.go | 77 ++++++++++++++++++++++ 5 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 internal/graph/overlay_carry_test.go diff --git a/cmd/gortex/wire_contract_test.go b/cmd/gortex/wire_contract_test.go index 182b8fd70..4fa167d80 100644 --- a/cmd/gortex/wire_contract_test.go +++ b/cmd/gortex/wire_contract_test.go @@ -123,16 +123,16 @@ func wireContractGolden(name string) string { // ProjectID, were added. return "a600bd906c9e09ffa0828427f5d6622acd5fe244798a61d5712c151053be5dac" case "graph.Edge": - // Bumped when NameOnly was added — the marker on a synthesized - // name-only candidate row (a call site that named the symbol but - // never bound to it, projected onto it by find_usages / - // get_callers under min_tier:"text_matched"). Set only on the - // in-memory projection, so a persisted edge always carries it - // false; not part of the edge identity / dedup key. - // (Previously bumped when Alias — the renamed name carried by a - // per-binding import or re-export edge — then Via, ReturnUsage, + // Bumped when Provisional was added — the marker on a stored call + // edge carried onto an overlay that re-parsed its file before + // relations were rebuilt. Set only on the in-memory overlay, so a + // persisted edge always carries it false; not part of the edge + // identity / dedup key. + // (Previously bumped when NameOnly — the marker on a synthesized + // name-only candidate row projected by find_usages / get_callers + // under min_tier:"text_matched" — then Alias, Via, ReturnUsage, // Context, and Tier, were added.) - return "d27d737c6c164243e2d55b4ca265a46d658e9617546f20306e9357e17aa46603" + return "fb8425eff2fe4f2b17d665fd182a48b6f5f5b832c90dc750ca4f933b639cee2c" default: panic(fmt.Sprintf("no golden fingerprint for %s", name)) } diff --git a/internal/graph/delta_writer_payload.go b/internal/graph/delta_writer_payload.go index e8b64f590..e5ee184a0 100644 --- a/internal/graph/delta_writer_payload.go +++ b/internal/graph/delta_writer_payload.go @@ -84,6 +84,7 @@ func deltaEdgeRender(e *Edge) string { c.Via = "" c.Alias = "" c.NameOnly = false + c.Provisional = false return fmt.Sprintf("%+v", c) } @@ -145,10 +146,10 @@ func deltaEdgeContentEqual(a, b *Edge) bool { } // deltaEdgeFieldsCompared is the number of Edge fields deltaEdgeContentEqual -// accounts for (the ten it compares, Meta, and the five that are not +// accounts for (the ten it compares, Meta, and the six that are not // persisted). An Edge with another field falls back to the rendered // comparison until the comparison learns it. -const deltaEdgeFieldsCompared = 16 +const deltaEdgeFieldsCompared = 17 var deltaEdgeFields = reflect.TypeOf(Edge{}).NumField() diff --git a/internal/graph/edge.go b/internal/graph/edge.go index 5413181ab..069e5f09a 100644 --- a/internal/graph/edge.go +++ b/internal/graph/edge.go @@ -621,6 +621,13 @@ type Edge struct { // only on the in-memory projection under min_tier:"text_matched"; // never persisted, never part of the edge identity / dedup key. NameOnly bool `json:"name_only,omitempty"` + // Provisional marks a stored relation carried onto an overlay that + // re-parsed its file before relations were rebuilt: the new content + // still references the same target from the same source, so the edge + // is served at the reference's new line until the reindex confirms + // it. Set only on the in-memory overlay; never persisted, never part + // of the edge identity / dedup key. + Provisional bool `json:"provisional,omitempty"` // Meta is intentionally excluded from JSON. It holds internal // instrumentation (semantic_source, provider hints, etc.) that agents // don't consume but that adds measurable bytes to every edge in diff --git a/internal/graph/overlay.go b/internal/graph/overlay.go index fc943eb54..ecd4b9dde 100644 --- a/internal/graph/overlay.go +++ b/internal/graph/overlay.go @@ -304,6 +304,95 @@ func (l *OverlayLayer) RebuildInEdges() { } } +// CarryBaseEdges returns provisional copies of the call edges base recorded in +// file that layerEdges, the file's re-parse, still make. A layer that covers a +// file hides every base edge recorded there, while the re-parse holds its calls +// as unresolved references until relations are rebuilt, so an edited function +// would otherwise lose its callees and drop out of its callees' callers. A base +// call is carried when the re-parse has an unresolved call from the same +// source to the target's name; the copy takes that call's line. Repeated calls +// pair up in line order, and a call the re-parse no longer makes is dropped. +func CarryBaseEdges(base Reader, file string, layerEdges []*Edge) []*Edge { + type site struct{ from, name string } + refs := make(map[site][]*Edge) + for _, e := range layerEdges { + if e == nil || e.Kind != EdgeCalls || !IsUnresolvedTarget(e.To) || IsFnValuePlaceholder(e.To) { + continue + } + key := site{e.From, unresolvedCallName(e.To)} + refs[key] = append(refs[key], e) + } + if base == nil || len(refs) == 0 { + return nil + } + for _, at := range refs { + sortEdgesByLine(at) + } + var ids []string + for _, n := range base.GetFileNodes(file) { + ids = append(ids, n.ID) + } + var recorded []*Edge + var targets []string + for _, edges := range base.GetOutEdgesByNodeIDs(ids) { + for _, e := range edges { + if e != nil && e.Kind == EdgeCalls && e.FilePath == file && !IsUnresolvedTarget(e.To) { + recorded = append(recorded, e) + targets = append(targets, e.To) + } + } + } + sortEdgesByLine(recorded) + names := base.GetNodesByIDs(targets) + used := make(map[site]int, len(refs)) + var carried []*Edge + for _, e := range recorded { + target := names[e.To] + if target == nil { + continue + } + key := site{e.From, target.Name} + at := refs[key] + if used[key] >= len(at) { + continue + } + c := *e + c.Line = at[used[key]].Line + c.Provisional = true + used[key]++ + carried = append(carried, &c) + } + return carried +} + +// unresolvedCallName is the bare callee name of an unresolved call target: +// `unresolved::Name`, `unresolved::*.name` and qualified forms all name their +// last segment. +func unresolvedCallName(id string) string { + name := UnresolvedName(id) + if i := strings.LastIndex(name, "::"); i >= 0 { + name = name[i+2:] + } + if i := strings.LastIndexByte(name, '.'); i >= 0 { + name = name[i+1:] + } + return name +} + +// sortEdgesByLine orders edges by source, line and target. +func sortEdgesByLine(edges []*Edge) { + sort.Slice(edges, func(i, j int) bool { + a, b := edges[i], edges[j] + if a.From != b.From { + return a.From < b.From + } + if a.Line != b.Line { + return a.Line < b.Line + } + return a.To < b.To + }) +} + // OverlaidView composes an immutable base Reader with a per-session // overlay layer. Every read path consults the layer first for paths // the overlay covers; falls through to base otherwise. The base is diff --git a/internal/graph/overlay_carry_test.go b/internal/graph/overlay_carry_test.go new file mode 100644 index 000000000..7d1a50129 --- /dev/null +++ b/internal/graph/overlay_carry_test.go @@ -0,0 +1,77 @@ +package graph + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestCarryBaseEdgesKeepsEditedCallerRelations re-parses a caller whose body +// was edited without touching its calls to Save: the edit adds two lines above +// both calls and drops the call to Load. Through the overlay the caller must +// keep calling Save, provisionally and at the calls' new lines, and stay among +// Save's callers; the dropped call is not carried. +func TestCarryBaseEdgesKeepsEditedCallerRelations(t *testing.T) { + base := New() + run := &Node{ID: "a.go::Run", Name: "Run", Kind: KindFunction, FilePath: "a.go", StartLine: 3, EndLine: 8} + save := &Node{ID: "b.go::Save", Name: "Save", Kind: KindFunction, FilePath: "b.go", StartLine: 1, EndLine: 1} + load := &Node{ID: "b.go::Load", Name: "Load", Kind: KindFunction, FilePath: "b.go", StartLine: 2, EndLine: 2} + for _, n := range []*Node{run, save, load} { + base.AddNode(n) + } + base.AddEdge(&Edge{From: run.ID, To: save.ID, Kind: EdgeCalls, FilePath: "a.go", Line: 4}) + base.AddEdge(&Edge{From: run.ID, To: load.ID, Kind: EdgeCalls, FilePath: "a.go", Line: 5}) + base.AddEdge(&Edge{From: run.ID, To: save.ID, Kind: EdgeCalls, FilePath: "a.go", Line: 6}) + + layer := NewOverlayLayer() + layer.MarkFile("a.go", false) + edited := *run + edited.EndLine = 9 + layer.AddNode("a.go", &edited) + reparsed := []*Edge{ + {From: run.ID, To: UnresolvedMarker + "Save", Kind: EdgeCalls, FilePath: "a.go", Line: 8}, + {From: run.ID, To: UnresolvedMarker + "*.Save", Kind: EdgeCalls, FilePath: "a.go", Line: 6}, + } + for _, e := range reparsed { + layer.AddEdge(e) + } + if got := NewOverlaidView(base, layer).GetInEdges(save.ID); len(got) != 0 { + t.Fatalf("a covered file still serves its recorded calls: %+v", got) + } + + carried := CarryBaseEdges(base, "a.go", reparsed) + for _, e := range carried { + layer.AddEdge(e) + } + view := NewOverlaidView(base, layer) + + var lines []int + for _, e := range view.GetInEdges(save.ID) { + if e.From != run.ID || !e.Provisional || e.FilePath != "a.go" { + t.Fatalf("Save's caller edge = %+v, want a provisional call from Run in a.go", e) + } + lines = append(lines, e.Line) + } + if len(lines) != 2 || lines[0] != 6 || lines[1] != 8 { + t.Fatalf("carried Save calls at lines %v, want [6 8]", lines) + } + var callees []string + for _, e := range view.GetOutEdges(run.ID) { + if !IsUnresolvedTarget(e.To) { + callees = append(callees, e.To) + } + } + if strings.Join(callees, ",") != save.ID+","+save.ID { + t.Fatalf("Run's resolved callees = %v, want Save twice and no Load", callees) + } + + plain := *carried[0] + plain.Provisional = false + if plain.IdentityHash() != carried[0].IdentityHash() { + t.Fatal("Provisional must not change an edge's identity") + } + raw, err := json.Marshal(carried[0]) + if err != nil || !strings.Contains(string(raw), `"provisional":true`) { + t.Fatalf("carried edge JSON = %s, %v; want provisional:true", raw, err) + } +} From b12c99611a52fa549a3e58b8968cebe5bf042a4a Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:17:19 +0100 Subject: [PATCH 02/61] indexer: report whether a file is tracked without a stat A read that finds a file drifted from the index has to tell a tracked file apart from one an editor or git created after indexing, and a tracked file missing on disk apart from an untracked one. The existing helpers fold an untracked path into "fresh" and stat the file to do it. IsTracked answers from the recorded mtimes alone. Refs #1014 --- internal/indexer/tracked.go | 18 ++++++++++++++++++ internal/indexer/tracked_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 internal/indexer/tracked.go create mode 100644 internal/indexer/tracked_test.go diff --git a/internal/indexer/tracked.go b/internal/indexer/tracked.go new file mode 100644 index 000000000..3b3a304f5 --- /dev/null +++ b/internal/indexer/tracked.go @@ -0,0 +1,18 @@ +package indexer + +import ( + "path/filepath" + + "github.com/zzet/gortex/internal/pathkey" +) + +// IsTracked reports whether the index holds the repo-relative relPath. Unlike +// TrackedFileState it never stats the file, so it separates a tracked file +// that is missing on disk from an untracked one an editor or git just created. +func (idx *Indexer) IsTracked(relPath string) bool { + relPath = pathkey.Normalize(filepath.ToSlash(relPath)) + idx.mtimeMu.RLock() + _, ok := idx.fileMtimes[relPath] + idx.mtimeMu.RUnlock() + return ok +} diff --git a/internal/indexer/tracked_test.go b/internal/indexer/tracked_test.go new file mode 100644 index 000000000..b58b22c2c --- /dev/null +++ b/internal/indexer/tracked_test.go @@ -0,0 +1,24 @@ +package indexer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/config" +) + +func TestIsTracked(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), []byte("package main\n\nfunc A() {}\n"), 0o644)) + idx := newExtractSourceTestIndexer(t, config.Default().Index) + _, err := idx.Index(dir) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.go"), []byte("package main\n\nfunc B() {}\n"), 0o644)) + require.NoError(t, os.Remove(filepath.Join(dir, "a.go"))) + require.True(t, idx.IsTracked("a.go"), "a deleted file stays tracked until it is evicted") + require.False(t, idx.IsTracked("b.go"), "a file created after indexing is not tracked") +} From 901428541280facfec11e27b38d409e84e265658 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:17:19 +0100 Subject: [PATCH 03/61] mcp: report read handlers that can reach a reindex Five read handlers refresh a drifted file by reindexing it synchronously, with no deadline, repeating the watcher's work for the same file and waiting behind the edit pipeline. Before reads go through pending edits instead, list every read tool whose handler can reach reindexFile, IncrementalReindexRepo or IncrementalReindexPaths. The guard builds a reference graph of the package's non-test files, follows each read tool's registered handler through it and reports the shortest chain to a reindex. It only reports for now and becomes a gate once read handlers stop reindexing; a fixture shows it catches a read handler that reindexes through a helper. Refs #1014 --- internal/mcp/read_path_guard_test.go | 169 +++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 internal/mcp/read_path_guard_test.go diff --git a/internal/mcp/read_path_guard_test.go b/internal/mcp/read_path_guard_test.go new file mode 100644 index 000000000..ce722910d --- /dev/null +++ b/internal/mcp/read_path_guard_test.go @@ -0,0 +1,169 @@ +package mcp + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/daemon" +) + +// reindexSinks reindex synchronously. A read handler that reaches one waits +// behind the edit pipeline and repeats the watcher's work for the same file. +var reindexSinks = map[string]bool{ + "reindexFile": true, + "IncrementalReindexRepo": true, + "IncrementalReindexPaths": true, +} + +// readPathGuardEnforced turns the guard from a report into a gate once read +// handlers read through pending edits instead of reindexing. +const readPathGuardEnforced = false + +// packageRefs maps every function declared in files, by bare name, to the +// names its body references, called or passed as a value, and every tool +// registered with addTool or addControlTool, as "tool:", to the names +// its handler expression references. Methods of different types that share a +// name merge, which can only over-report a path. +func packageRefs(files map[string]*ast.File) map[string]map[string]bool { + refs := make(map[string]map[string]bool) + collect := func(key string, n ast.Node) { + set := refs[key] + if set == nil { + set = make(map[string]bool) + refs[key] = set + } + ast.Inspect(n, func(n ast.Node) bool { + if ident, ok := n.(*ast.Ident); ok { + set[ident.Name] = true + } + return true + }) + } + for _, file := range files { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + collect(fn.Name.Name, fn.Body) + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || len(call.Args) != 2 { + return true + } + register, ok := call.Fun.(*ast.SelectorExpr) + if !ok || (register.Sel.Name != "addTool" && register.Sel.Name != "addControlTool") { + return true + } + tool, ok := call.Args[0].(*ast.CallExpr) + if !ok || len(tool.Args) == 0 { + return true + } + lit, ok := tool.Args[0].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + if name, err := strconv.Unquote(lit.Value); err == nil { + collect("tool:"+name, call.Args[1]) + } + return true + }) + } + } + return refs +} + +// readReindexPaths returns, for every read tool whose handler can reach a +// reindex sink, the shortest reference chain to it. +func readReindexPaths(files map[string]*ast.File, isRead func(tool string) bool) []string { + refs := packageRefs(files) + var out []string + for key := range refs { + tool, ok := strings.CutPrefix(key, "tool:") + if !ok || !isRead(tool) { + continue + } + parent := map[string]string{key: ""} + queue := []string{key} + search: + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + names := make([]string, 0, len(refs[cur])) + for name := range refs[cur] { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if reindexSinks[name] { + chain := []string{name} + for at := cur; at != ""; at = parent[at] { + chain = append(chain, at) + } + slices.Reverse(chain) + out = append(out, strings.Join(chain, " -> ")) + break search + } + if _, declared := refs[name]; !declared { + continue + } + if _, seen := parent[name]; !seen { + parent[name] = cur + queue = append(queue, name) + } + } + } + } + sort.Strings(out) + return out +} + +// TestReadHandlersNeverReachReindex lists the read tools whose handlers can +// reach a synchronous reindex. It reports until reads go through pending +// edits, then gates. +func TestReadHandlersNeverReachReindex(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 + } + isRead := func(tool string) bool { return !daemon.IsEffectful(tool) } + violations := readReindexPaths(files, isRead) + for _, violation := range violations { + t.Log("read handler reaches a reindex: " + violation) + } + if readPathGuardEnforced { + require.Empty(t, violations) + } + + // The guard must follow a read handler through a helper and leave + // mutating tools alone. + fixture, err := parser.ParseFile(fset, "fixture.go", `package mcp +func (s *Server) registerFixture() { + s.addTool(mcp.NewTool("read_fixture"), s.handleReadFixture) + s.addTool(mcp.NewTool("edit_fixture"), s.handleEditFixture) +} +func (s *Server) handleReadFixture() { s.refresh() } +func (s *Server) refresh() { s.reindexFile("x") } +func (s *Server) handleEditFixture() { s.reindexFile("x") } +`, parser.SkipObjectResolution) + require.NoError(t, err) + require.Equal(t, []string{"tool:read_fixture -> handleReadFixture -> refresh -> reindexFile"}, + readReindexPaths(map[string]*ast.File{"fixture.go": fixture}, func(tool string) bool { return tool == "read_fixture" })) +} From b3977e69eec9ef0595fa28a015f4b865cceade7b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 03:28:58 +0100 Subject: [PATCH 04/61] Complete bounded projected reads and selected-view retirement --- internal/graph/file_editing_context.go | 2 +- .../graph/file_editing_context_context.go | 162 +++++++ .../file_editing_context_context_test.go | 43 ++ internal/graph/store.go | 5 +- .../file_editing_context_context.go | 172 +++++++ .../file_editing_context_context_test.go | 96 ++++ .../generation_layer_editing_context.go | 112 +++++ .../generation_layer_editing_context_test.go | 97 ++++ .../contract_core_capability_table_test.go | 3 + internal/mcp/contract_core_editing_context.go | 91 +++- ...tract_core_editing_context_context_test.go | 47 ++ .../mcp/contract_core_editing_context_test.go | 18 + internal/mcp/ensure_fresh_self_heal_test.go | 45 +- internal/mcp/facade_tools.go | 1 + .../freshness_idle_budget_contract_test.go | 2 +- .../mcp/freshness_response_contract_test.go | 50 +- internal/mcp/freshness_rider.go | 69 ++- internal/mcp/move_file_observation.go | 2 +- internal/mcp/move_file_observation_test.go | 26 +- .../move_read_assisted_observation_test.go | 95 ++-- internal/mcp/overlay.go | 1 + internal/mcp/overlay_view.go | 11 +- internal/mcp/pending_projection.go | 34 +- .../mcp/projected_read_pinned_request_test.go | 106 ++++ .../mcp/projected_read_publication_test.go | 159 ++++++ internal/mcp/projected_read_runtime_test.go | 270 +++++++++++ internal/mcp/projected_reads.go | 372 ++++++++++++++ internal/mcp/read_path_guard_test.go | 2 +- internal/mcp/read_repair_observation.go | 24 - internal/mcp/relation_freshness.go | 11 +- .../mcp/selected_projection_retirement.go | 202 ++++++++ .../selected_projection_retirement_test.go | 459 ++++++++++++++++++ internal/mcp/tools_cfg.go | 12 +- internal/mcp/tools_coding.go | 235 +++------ internal/mcp/tools_core.go | 12 +- internal/mcp/tools_enhancements.go | 85 ---- internal/mcp/tools_enhancements_test.go | 70 --- 37 files changed, 2707 insertions(+), 496 deletions(-) create mode 100644 internal/graph/file_editing_context_context.go create mode 100644 internal/graph/file_editing_context_context_test.go create mode 100644 internal/graph/store_sqlite/file_editing_context_context.go create mode 100644 internal/graph/store_sqlite/file_editing_context_context_test.go create mode 100644 internal/graphview/generation_layer_editing_context.go create mode 100644 internal/graphview/generation_layer_editing_context_test.go create mode 100644 internal/mcp/contract_core_editing_context_context_test.go create mode 100644 internal/mcp/projected_read_pinned_request_test.go create mode 100644 internal/mcp/projected_read_publication_test.go create mode 100644 internal/mcp/projected_read_runtime_test.go create mode 100644 internal/mcp/projected_reads.go create mode 100644 internal/mcp/selected_projection_retirement.go create mode 100644 internal/mcp/selected_projection_retirement_test.go diff --git a/internal/graph/file_editing_context.go b/internal/graph/file_editing_context.go index 0d515a559..8117923dd 100644 --- a/internal/graph/file_editing_context.go +++ b/internal/graph/file_editing_context.go @@ -11,7 +11,7 @@ import "sort" // // Every read is a batched Reader call, so a reader wrapper that narrows // adjacency gets the same fixed round-trip count with its own edge semantics. -func FileEditingContextOf(r Reader, filePath string, kinds []NodeKind) *FileEditingContextResult { +func FileEditingContextOf(r FileEditingContextRows, filePath string, kinds []NodeKind) *FileEditingContextResult { if filePath == "" { return nil } diff --git a/internal/graph/file_editing_context_context.go b/internal/graph/file_editing_context_context.go new file mode 100644 index 000000000..3f89c5e17 --- /dev/null +++ b/internal/graph/file_editing_context_context.go @@ -0,0 +1,162 @@ +package graph + +import ( + "context" + "errors" +) + +var ErrFileEditingContextUnsupported = errors.New("cancellable editing context unsupported") + +// FileEditingContextRows is the full-field input to the editing-context bundle. +// It deliberately exposes no mutation, scan, transaction or unrelated graph API. +type FileEditingContextRows interface { + GetFileNodes(string) []*Node + GetNode(string) *Node + GetOutEdges(string) []*Edge + GetNodesByIDs([]string) map[string]*Node + GetOutEdgesByNodeIDs([]string) map[string][]*Edge + GetInEdgesByNodeIDs([]string) map[string][]*Edge +} + +// FileEditingContextReadScope lends a reader only for the synchronous bundle +// callback. The callback may use only the five reads in FileEditingContextOf +// (and GetNode for overlay endpoint visibility). It must not retain the reader. +// Implementations reject cancellation, partial rows and changed revisions after +// the callback; a callback result is usable only when this method returns nil. +// This narrow scope is not a general Reader transaction API. +type FileEditingContextReadScope interface { + WithFileEditingContextReader(context.Context, func(context.Context, FileEditingContextRows) error) error +} + +type FileEditingContextLayerScope interface { + WithFileEditingContextLayer(context.Context, func(context.Context, OverlayLayerReader) error) error +} + +func WithFileEditingContextReader(ctx context.Context, r Reader, fn func(context.Context, FileEditingContextRows) error) error { + if ctx == nil { + return ErrFileEditingContextUnsupported + } + if err := ctx.Err(); err != nil { + return err + } + scoped, ok := r.(FileEditingContextReadScope) + if !ok { + return ErrFileEditingContextUnsupported + } + return scoped.WithFileEditingContextReader(ctx, fn) +} + +func (v *OverlaidView) WithFileEditingContextReader(ctx context.Context, fn func(context.Context, FileEditingContextRows) error) error { + if ctx == nil { + return ErrFileEditingContextUnsupported + } + if err := ctx.Err(); err != nil { + return err + } + withLayer := func(ctx context.Context, base FileEditingContextRows) error { + if v.layer == nil { + return fn(ctx, NewOverlaidViewWithLayer(editingContextReaderOf(base), nil)) + } + layer, ok := v.layer.(FileEditingContextLayerScope) + if !ok { + return ErrFileEditingContextUnsupported + } + return layer.WithFileEditingContextLayer(ctx, func(ctx context.Context, l OverlayLayerReader) error { + return fn(ctx, &editingContextView{NewOverlaidViewWithLayer(editingContextReaderOf(base), l)}) + }) + } + if v.base == nil { + return withLayer(ctx, nil) + } + return WithFileEditingContextReader(ctx, v.base, withLayer) +} + +func (g *Graph) WithFileEditingContextReader(ctx context.Context, fn func(context.Context, FileEditingContextRows) error) error { + if ctx == nil { + return ErrFileEditingContextUnsupported + } + if err := ctx.Err(); err != nil { + return err + } + revision := g.MutationRevision() + if err := fn(ctx, g); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if revision != g.MutationRevision() { + return ErrContractProjectionStale + } + return nil +} +func (l *OverlayLayer) WithFileEditingContextLayer(ctx context.Context, fn func(context.Context, OverlayLayerReader) error) error { + if ctx == nil { + return ErrFileEditingContextUnsupported + } + if err := ctx.Err(); err != nil { + return err + } + if err := fn(ctx, l); err != nil { + return err + } + return ctx.Err() +} + +// The ephemeral composition only invokes the six bundle reads. A nil embedded +// Reader makes any accidental expansion fail rather than reach a live backend. +type editingContextReader struct { + Reader + FileEditingContextRows +} + +func editingContextReaderOf(rows FileEditingContextRows) Reader { + if rows == nil { + return nil + } + return &editingContextReader{FileEditingContextRows: rows} +} +func (r *editingContextReader) GetFileNodes(path string) []*Node { + return r.FileEditingContextRows.GetFileNodes(path) +} +func (r *editingContextReader) GetNode(id string) *Node { return r.FileEditingContextRows.GetNode(id) } +func (r *editingContextReader) GetOutEdges(id string) []*Edge { + return r.FileEditingContextRows.GetOutEdges(id) +} +func (r *editingContextReader) GetNodesByIDs(ids []string) map[string]*Node { + return r.FileEditingContextRows.GetNodesByIDs(ids) +} +func (r *editingContextReader) GetOutEdgesByNodeIDs(ids []string) map[string][]*Edge { + return r.FileEditingContextRows.GetOutEdgesByNodeIDs(ids) +} +func (r *editingContextReader) GetInEdgesByNodeIDs(ids []string) map[string][]*Edge { + return r.FileEditingContextRows.GetInEdgesByNodeIDs(ids) +} + +// Keep durable incoming reads batched without advertising the unrelated +// OverlayLayerProjectionReader scans on the narrow borrowed layer. +type editingContextLayerIncoming interface { + FileEditingContextInEdgesByNodeIDs([]string) map[string][]*Edge +} +type editingContextView struct{ *OverlaidView } + +func (v *editingContextView) GetInEdgesByNodeIDs(ids []string) map[string][]*Edge { + layer, ok := v.layer.(editingContextLayerIncoming) + if !ok { + return v.OverlaidView.GetInEdgesByNodeIDs(ids) + } + out := make(map[string][]*Edge, len(ids)) + if v.base != nil { + for id, edges := range v.base.GetInEdgesByNodeIDs(ids) { + for _, edge := range edges { + if v.baseEdgeVisible(edge) { + out[id] = append(out[id], edge) + } + } + } + } + for id, edges := range layer.FileEditingContextInEdgesByNodeIDs(ids) { + out[id] = append(out[id], edges...) + } + return out +} diff --git a/internal/graph/file_editing_context_context_test.go b/internal/graph/file_editing_context_context_test.go new file mode 100644 index 000000000..c8f9f9c49 --- /dev/null +++ b/internal/graph/file_editing_context_context_test.go @@ -0,0 +1,43 @@ +package graph + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// The old contextual node capability must not be mistaken for a safe bundle +// layer: OverlaidView's legacy batch refetch still calls layer.NodeByID. +type editingContextUnsafeLayer struct{ OverlayLayerReader } + +func (l *editingContextUnsafeLayer) NodeByID(string) *Node { panic("contextless physical node lookup") } +func (l *editingContextUnsafeLayer) GetNodesByIDsContext(context.Context, []string) (map[string]*Node, error) { + return nil, nil +} + +func TestEditingContextScopeRejectsContextualLookingLegacyLayer(t *testing.T) { + layer := NewOverlayLayer() + layer.MarkFile("a.go", false) + view := NewOverlaidViewWithLayer(New(), &editingContextUnsafeLayer{layer}) + called := false + err := WithFileEditingContextReader(t.Context(), view, func(context.Context, FileEditingContextRows) error { called = true; return nil }) + require.ErrorIs(t, err, ErrFileEditingContextUnsupported) + require.False(t, called) +} + +func TestEditingContextScopeMemoryOwnershipParity(t *testing.T) { + base := New() + base.AddNode(&Node{ID: "a.go::f", Kind: KindFunction, FilePath: "a.go", Meta: map[string]any{"signature": "base"}}) + base.AddNode(&Node{ID: "b.go::g", Kind: KindFunction, FilePath: "b.go", Meta: map[string]any{"full": "neighbor"}}) + base.AddEdge(&Edge{From: "b.go::g", To: "a.go::f", Kind: EdgeCalls, FilePath: "b.go"}) + layer := NewOverlayLayer() + layer.MarkFile("a.go", false) + layer.AddNode("a.go", &Node{ID: "a.go::f", Kind: KindFunction, FilePath: "a.go", Meta: map[string]any{"signature": "selected"}}) + view := NewOverlaidView(base, layer) + want := FileEditingContextOf(view, "a.go", []NodeKind{KindFunction}) + require.NoError(t, WithFileEditingContextReader(t.Context(), view, func(_ context.Context, rows FileEditingContextRows) error { + require.Equal(t, want, FileEditingContextOf(rows, "a.go", []NodeKind{KindFunction})) + return nil + })) +} diff --git a/internal/graph/store.go b/internal/graph/store.go index ddbdfb58b..dc2d92b41 100644 --- a/internal/graph/store.go +++ b/internal/graph/store.go @@ -2621,8 +2621,9 @@ type ClassHierarchyTraverser interface { // any defines node, filtered to symbols outside the file). All five // projections are scoped to the input file in one round-trip each. // -// Optional capability — handleGetEditingContext falls back to the -// per-symbol loop when the backend doesn't implement it. +// Legacy synchronous capability. The MCP handler uses FileEditingContextReadScope +// so cancellation, incomplete reads and selected-layer revisions are checked +// before accepting a bundle; it never falls back to this errorless interface. type FileEditingContextResult struct { FileNode *Node Defines []*Node diff --git a/internal/graph/store_sqlite/file_editing_context_context.go b/internal/graph/store_sqlite/file_editing_context_context.go new file mode 100644 index 000000000..228ec87d1 --- /dev/null +++ b/internal/graph/store_sqlite/file_editing_context_context.go @@ -0,0 +1,172 @@ +package store_sqlite + +import ( + "context" + "database/sql" + + "github.com/zzet/gortex/internal/graph" +) + +type editingContextScopeKey struct{} +type editingContextTransaction struct { + core *storeCore + tx *sql.Tx + parent *editingContextTransaction + budget int + err error +} + +// WithFileEditingContextReader keeps all full-row bundle reads in one read +// transaction. Selected generations on the same core reuse that transaction, +// avoiding a nested acquisition of SQLite's sole reader connection. +func (s *Store) WithFileEditingContextReader(ctx context.Context, fn func(context.Context, graph.FileEditingContextRows) error) error { + if ctx == nil || s == nil || s.coreless() { + return graph.ErrFileEditingContextUnsupported + } + if err := ctx.Err(); err != nil { + return err + } + if s.db == nil { + return sql.ErrConnDone + } + revision, err := s.PayloadInputRevisionContext(ctx) + if err != nil { + return err + } + head, _ := ctx.Value(editingContextScopeKey{}).(*editingContextTransaction) + scope := head + for scope != nil && scope.core != s.storeCore { + scope = scope.parent + } + owned := scope == nil + if owned { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + scope = &editingContextTransaction{core: s.storeCore, tx: tx, parent: head, budget: graph.ContractProjectionRowLimit} + ctx = context.WithValue(ctx, editingContextScopeKey{}, scope) + } + reader := &editingContextSQLReader{ctx: ctx, store: s, scope: scope, nodesByID: make(map[string]*graph.Node), knownIDs: make(map[string]bool)} + if err := fn(ctx, reader); err != nil { + return err + } + if scope.err != nil { + return scope.err + } + if err := ctx.Err(); err != nil { + return err + } + after, err := s.PayloadInputRevisionContext(ctx) + if err != nil { + return err + } + if after != revision { + return graph.ErrContractProjectionStale + } + if owned { + return scope.tx.Commit() + } + return nil +} + +// Only the synchronous editing-context reads are exposed; no live store reader +// is embedded, so a new operation cannot silently bypass this transaction. +type editingContextSQLReader struct { + ctx context.Context + store *Store + scope *editingContextTransaction + nodesByID map[string]*graph.Node + knownIDs map[string]bool +} + +func (r *editingContextSQLReader) nodes(column string, keys []string) []*graph.Node { + if r.scope.err != nil { + return nil + } + var result []*graph.Node + keys = dedupeNonEmpty(keys) + for start := 0; start < len(keys); start += lookupChunkSize { + chunk := keys[start:min(start+lookupChunkSize, len(keys))] + args := append(toAnyArgs(chunk), r.store.viewGen, r.scope.budget+1) + query := `SELECT ` + lookupNodeCols + ` FROM nodes WHERE ` + column + ` IN (` + inPlaceholders(len(chunk)) + `) AND view_gen=? LIMIT ?` + rows, err := r.store.contractProjectionNodes(r.ctx, r.scope.tx, query, args, &r.scope.budget) + if err != nil { + r.scope.err = err + return nil + } + for _, node := range rows { + r.nodesByID[node.ID] = node + r.knownIDs[node.ID] = true + } + result = append(result, rows...) + } + return result +} +func (r *editingContextSQLReader) GetFileNodes(path string) []*graph.Node { + return r.nodes("file_path", []string{path}) +} +func (r *editingContextSQLReader) GetNodesByIDs(ids []string) map[string]*graph.Node { + out := make(map[string]*graph.Node) + var missing []string + for _, id := range dedupeNonEmpty(ids) { + if !r.knownIDs[id] { + missing = append(missing, id) + } + } + rows := r.nodes("id", missing) + if r.scope.err != nil { + return nil + } + for _, id := range missing { + r.knownIDs[id] = true + } + for _, n := range rows { + r.nodesByID[n.ID] = n + } + for _, id := range ids { + if n := r.nodesByID[id]; n != nil { + out[id] = n + } + } + return out +} + +func (r *editingContextSQLReader) GetNode(id string) *graph.Node { + return r.GetNodesByIDs([]string{id})[id] +} +func (r *editingContextSQLReader) edges(column string, ids []string) map[string][]*graph.Edge { + if r.scope.err != nil { + return nil + } + out := make(map[string][]*graph.Edge) + ids = dedupeNonEmpty(ids) + for start := 0; start < len(ids); start += lookupChunkSize { + chunk := ids[start:min(start+lookupChunkSize, len(ids))] + args := append(toAnyArgs(chunk), r.store.viewGen, r.scope.budget+1) + query := `SELECT COALESCE(n.repo_prefix,''), ` + contractProjectionEdgeColumns() + ` FROM edges AS e LEFT JOIN nodes AS n ON n.id=e.from_id AND n.view_gen=e.view_gen WHERE e.` + column + ` IN (` + inPlaceholders(len(chunk)) + `) AND e.view_gen=? LIMIT ?` + rows, err := r.store.contractProjectionEdges(r.ctx, r.scope.tx, query, args, &r.scope.budget) + if err != nil { + r.scope.err = err + return nil + } + for _, row := range rows { + id := row.Edge.From + if column == "to_id" { + id = row.Edge.To + } + out[id] = append(out[id], row.Edge) + } + } + return out +} +func (r *editingContextSQLReader) GetOutEdges(id string) []*graph.Edge { + return r.GetOutEdgesByNodeIDs([]string{id})[id] +} +func (r *editingContextSQLReader) GetOutEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { + return r.edges("from_id", ids) +} +func (r *editingContextSQLReader) GetInEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { + return r.edges("to_id", ids) +} diff --git a/internal/graph/store_sqlite/file_editing_context_context_test.go b/internal/graph/store_sqlite/file_editing_context_context_test.go new file mode 100644 index 000000000..8d5e675ac --- /dev/null +++ b/internal/graph/store_sqlite/file_editing_context_context_test.go @@ -0,0 +1,96 @@ +package store_sqlite + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +type editingContextScope interface { + WithFileEditingContextReader(context.Context, func(context.Context, graph.FileEditingContextRows) error) error +} + +func TestEditingContextSnapshotFullParityAndCancellation(t *testing.T) { + s := newTestProjectionStore(t) + nodes := []*graph.Node{ + {ID: "a.go", Kind: graph.KindFile, FilePath: "a.go", Meta: map[string]any{"payload": "file"}}, + {ID: "a.go::f", Kind: graph.KindFunction, FilePath: "a.go", Meta: map[string]any{"signature": "full"}}, + {ID: "b.go::g", Kind: graph.KindFunction, FilePath: "b.go", Meta: map[string]any{"payload": "neighbor"}}, + } + s.AddBatch(nodes, []*graph.Edge{ + {From: "a.go", To: "external", Kind: graph.EdgeImports, FilePath: "a.go", Meta: map[string]any{"external": true}}, + {From: "a.go::f", To: "b.go::g", Kind: graph.EdgeCalls, FilePath: "a.go", Meta: map[string]any{"payload": "out"}}, + {From: "b.go::g", To: "a.go::f", Kind: graph.EdgeCalls, FilePath: "b.go", Meta: map[string]any{"payload": "in"}}, + }) + scope, ok := any(s).(editingContextScope) + require.True(t, ok, "physical editing bundle must supply a cancellable coherent read scope") + want := s.FileEditingContext("a.go", []graph.NodeKind{graph.KindFunction}) + require.NoError(t, scope.WithFileEditingContextReader(context.Background(), func(_ context.Context, r graph.FileEditingContextRows) error { + require.Equal(t, want, graph.FileEditingContextOf(r, "a.go", []graph.NodeKind{graph.KindFunction})) + return nil + })) + // Occupy the sole physical reader connection. The bundle must time out at + // transaction admission rather than wait in an errorless legacy lookup. + s.db.SetMaxOpenConns(1) + conn, err := s.db.Conn(context.Background()) + require.NoError(t, err) + defer func() { require.NoError(t, conn.Close()) }() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + called := false + err = scope.WithFileEditingContextReader(ctx, func(context.Context, graph.FileEditingContextRows) error { called = true; return nil }) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.False(t, called) +} + +func TestEditingContextSnapshotRefusesPartialAndReusesCore(t *testing.T) { + s := newTestProjectionStore(t) + s.AddBatch([]*graph.Node{{ID: "a.go::f", Kind: graph.KindFunction, FilePath: "a.go", Meta: map[string]any{"signature": "full"}}}, nil) + scope, ok := any(s).(editingContextScope) + require.True(t, ok) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, scope.WithFileEditingContextReader(ctx, func(ctx context.Context, _ graph.FileEditingContextRows) error { + return scope.WithFileEditingContextReader(ctx, func(_ context.Context, r graph.FileEditingContextRows) error { + require.NotNil(t, graph.FileEditingContextOf(r, "a.go", nil)) + return nil + }) + })) + _, err := s.writerDB.Exec(`UPDATE nodes SET meta=? WHERE id=?`, []byte("invalid metadata"), "a.go::f") + require.NoError(t, err) + var got *graph.FileEditingContextResult + err = scope.WithFileEditingContextReader(context.Background(), func(_ context.Context, r graph.FileEditingContextRows) error { + got = graph.FileEditingContextOf(r, "a.go", nil) + return nil + }) + require.Error(t, err, "an invalid full row cannot certify an empty or partial bundle") + require.Nil(t, got) +} + +func TestEditingContextSnapshotLimitAndRevisionRefusal(t *testing.T) { + s := newTestProjectionStore(t) + node := &graph.Node{ID: "a.go::f", Kind: graph.KindFunction, FilePath: "a.go"} + neighbor := &graph.Node{ID: "b.go::g", Kind: graph.KindFunction, FilePath: "b.go", Meta: map[string]any{"signature": "before snapshot"}} + require.NoError(t, s.AddBatchChecked([]*graph.Node{node, neighbor}, nil)) + err := s.WithFileEditingContextReader(t.Context(), func(ctx context.Context, rows graph.FileEditingContextRows) error { + scope := ctx.Value(editingContextScopeKey{}).(*editingContextTransaction) + scope.budget = 0 + require.Nil(t, rows.GetFileNodes("a.go")) + return nil + }) + require.ErrorIs(t, err, graph.ErrContractProjectionLimit) + err = s.WithFileEditingContextReader(t.Context(), func(_ context.Context, rows graph.FileEditingContextRows) error { + require.Len(t, rows.GetFileNodes("a.go"), 1) + updated := *neighbor + updated.Meta = map[string]any{"signature": "after snapshot"} + require.NoError(t, s.AddBatchChecked([]*graph.Node{&updated}, nil)) + // This identity has not been hydrated or cached yet: observing the old + // full payload proves the later query shares the established read tx. + require.Equal(t, neighbor.Meta, rows.GetNodesByIDs([]string{neighbor.ID})[neighbor.ID].Meta) + return nil + }) + require.ErrorIs(t, err, graph.ErrContractProjectionStale, "a mutation across the snapshot must refuse publication") +} diff --git a/internal/graphview/generation_layer_editing_context.go b/internal/graphview/generation_layer_editing_context.go new file mode 100644 index 000000000..a34c6ddca --- /dev/null +++ b/internal/graphview/generation_layer_editing_context.go @@ -0,0 +1,112 @@ +package graphview + +import ( + "context" + "iter" + + "github.com/zzet/gortex/internal/graph" +) + +// The view lease still owns this layer. The borrowed adapter changes only +// physical row reads; immutable masks and identity claims remain this layer's. +func (l *GenerationLayer) WithFileEditingContextLayer(ctx context.Context, fn func(context.Context, graph.OverlayLayerReader) error) error { + if err := l.checkContractInputRevision(ctx); err != nil { + return err + } + err := l.handle.WithFileEditingContextReader(ctx, func(ctx context.Context, r graph.FileEditingContextRows) error { + return fn(ctx, &editingContextLayer{OverlayLayerReader: l, layer: l, reader: r}) + }) + if err != nil { + return err + } + return l.checkContractInputRevision(ctx) +} + +type editingContextLayer struct { + graph.OverlayLayerReader + layer *GenerationLayer + reader graph.FileEditingContextRows +} + +func (l *editingContextLayer) NodeByID(id string) *graph.Node { + n := l.reader.GetNode(id) + if !l.layer.servesNode(n) { + return nil + } + return n +} +func (l *editingContextLayer) FileNodes(path string) []*graph.Node { + return l.layer.serveNodes(l.reader.GetFileNodes(path)) +} +func (l *editingContextLayer) filter(rows []*graph.Edge) []*graph.Edge { + var out []*graph.Edge + for _, e := range rows { + if l.layer.servesEdge(e) { + out = append(out, e) + } + } + return out +} +func (l *editingContextLayer) OutEdges(id string) []*graph.Edge { + return l.filter(l.reader.GetOutEdges(id)) +} +func (l *editingContextLayer) InEdges(id string) []*graph.Edge { + return l.filter(l.reader.GetInEdgesByNodeIDs([]string{id})[id]) +} + +// OwnsNodeIdentity must not delegate to GenerationLayer's cached legacy +// method: its covered-row membership probe calls contextless NodeByID. +func (l *editingContextLayer) OwnsNodeIdentity(id string) bool { + if id == "" { + return false + } + if l.layer.IsRemovedID(id) { + return true + } + if _, ok := l.layer.detachedIDs[id]; ok { + return true + } + if _, ok := l.layer.claimedIDs[id]; ok { + return true + } + return l.layer.CoversNodeID(id) && l.NodeByID(id) != nil +} +func (l *editingContextLayer) DetachedNodeSummaries() iter.Seq[*graph.Node] { + return l.layer.DetachedNodeSummaries() +} +func (l *editingContextLayer) detached(match func(graph.Node) bool) []*graph.Node { + var ids []string + for _, n := range l.layer.detachedNodes { + if match(n) { + ids = append(ids, n.ID) + } + } + rows := l.reader.GetNodesByIDs(ids) + var out []*graph.Node + for _, id := range ids { + if n := rows[id]; l.layer.servesNode(n) { + out = append(out, n) + } + } + return out +} +func (l *editingContextLayer) DetachedFileNodes(path string) []*graph.Node { + return l.detached(func(n graph.Node) bool { return n.FilePath == path }) +} +func (l *editingContextLayer) DetachedRepoNodes(repo string) []*graph.Node { + return l.detached(func(n graph.Node) bool { return n.RepoPrefix == repo }) +} + +func (l *editingContextLayer) filterBatch(rows map[string][]*graph.Edge) map[string][]*graph.Edge { + out := make(map[string][]*graph.Edge, len(rows)) + for id, edges := range rows { + out[id] = l.filter(edges) + } + return out +} +func (l *editingContextLayer) GetOutEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { + return l.filterBatch(l.reader.GetOutEdgesByNodeIDs(ids)) +} +func (l *editingContextLayer) FileEditingContextInEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { + return l.filterBatch(l.reader.GetInEdgesByNodeIDs(ids)) +} diff --git a/internal/graphview/generation_layer_editing_context_test.go b/internal/graphview/generation_layer_editing_context_test.go new file mode 100644 index 000000000..8bc20f406 --- /dev/null +++ b/internal/graphview/generation_layer_editing_context_test.go @@ -0,0 +1,97 @@ +package graphview + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +func TestEditingContextSelectedGenerationFullParityAndStale(t *testing.T) { + store := openTestStore(t) + file, neighbor := "repo/a.go", "repo/b.go" + base := []*graph.Node{ + {ID: file, Kind: graph.KindFile, FilePath: file, RepoPrefix: "repo"}, + {ID: file + "::f", Kind: graph.KindFunction, FilePath: file, RepoPrefix: "repo", Meta: map[string]any{"signature": "old"}}, + {ID: neighbor + "::g", Kind: graph.KindFunction, FilePath: neighbor, RepoPrefix: "repo", Meta: map[string]any{"signature": "full neighbor"}}, + } + require.NoError(t, store.AddBatchChecked(base, []*graph.Edge{{From: base[2].ID, To: base[1].ID, Kind: graph.EdgeCalls, FilePath: neighbor, Meta: map[string]any{"full": "caller"}}})) + _, handle := beginTestGeneration(t, store, "editing-context") + current := &graph.Node{ID: base[1].ID, Kind: graph.KindFunction, FilePath: file, RepoPrefix: "repo", Meta: map[string]any{"signature": "new"}} + require.NoError(t, handle.AddBatchChecked([]*graph.Node{base[0], current}, []*graph.Edge{{From: current.ID, To: base[2].ID, Kind: graph.EdgeCalls, FilePath: file, Meta: map[string]any{"full": "selected callee"}}})) + require.NoError(t, handle.SetFileMasks([]store_sqlite.FileMask{{FilePath: file, Mode: store_sqlite.OwnershipReplace}})) + publishTestGeneration(t, store, handle.ViewGeneration()) + layer, err := NewGenerationLayer(handle) + require.NoError(t, err) + view := graph.NewOverlaidViewWithLayer(store, layer) + // The scoped read runs first, before the legacy read can populate the + // layer's NodeByID cache, so parity cannot depend on an earlier legacy + // hydration. The separate SQLite admission control fixes its pool at one. + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + var got *graph.FileEditingContextResult + require.NoError(t, graph.WithFileEditingContextReader(ctx, view, func(_ context.Context, rows graph.FileEditingContextRows) error { + got = graph.FileEditingContextOf(rows, file, []graph.NodeKind{graph.KindFunction}) + return nil + })) + require.Equal(t, graph.FileEditingContextOf(view, file, []graph.NodeKind{graph.KindFunction}), got) + require.Equal(t, "new", got.Defines[0].Meta["signature"]) + require.Equal(t, base[2].Meta, got.CalledBy[0].Meta) + require.Len(t, got.Calls, 1) + require.Equal(t, base[2].Meta, got.Calls[0].Meta) + correction, err := store.BeginDerivedCorrection(t.Context(), store_sqlite.DerivedCorrectionRequest{GenerationID: handle.ViewGeneration(), Pass: "capability", FromVersion: 0, ToVersion: 1, EdgeKinds: []graph.EdgeKind{graph.EdgeAccessesField}}) + require.NoError(t, err) + require.NoError(t, correction.ReplaceSourceEdges(t.Context(), []string{current.ID}, []*graph.Edge{{From: current.ID, To: base[2].ID, Kind: graph.EdgeAccessesField, FilePath: file}}, nil)) + err = graph.WithFileEditingContextReader(t.Context(), view, func(context.Context, graph.FileEditingContextRows) error { + t.Fatal("stale layer invoked bundle callback") + return nil + }) + require.ErrorIs(t, err, graph.ErrContractProjectionStale) + _, err = correction.Finish(t.Context()) + require.NoError(t, err) +} + +func TestEditingContextStagedContradictionsDoNotOverrideOwnership(t *testing.T) { + store := openTestStore(t) + file, other := "repo/a.go", "repo/other.go" + function := &graph.Node{ID: file + "::f", Kind: graph.KindFunction, FilePath: file, RepoPrefix: "repo", Meta: map[string]any{"signature": "old"}} + sibling := &graph.Node{ID: file + "::sibling", Kind: graph.KindType, FilePath: file, RepoPrefix: "repo", Meta: map[string]any{"signature": "lower sibling"}} + neighbor := &graph.Node{ID: other + "::caller", Kind: graph.KindFunction, FilePath: other, RepoPrefix: "repo", Meta: map[string]any{"signature": "lower caller"}} + require.NoError(t, store.AddBatchChecked([]*graph.Node{function, sibling, neighbor}, []*graph.Edge{{From: neighbor.ID, To: function.ID, Kind: graph.EdgeCalls, FilePath: other, Meta: map[string]any{"full": "lower adjacency"}}, {From: function.ID, To: neighbor.ID, Kind: graph.EdgeCalls, FilePath: file, Meta: map[string]any{"full": "identity replacement keeps lower outgoing"}}})) + _, handle := beginTestGeneration(t, store, "editing-context-detached") + current := *function + current.Meta = map[string]any{"signature": "selected full"} + poisonSibling := *sibling + poisonSibling.Meta = map[string]any{"signature": "unclaimed poison"} + poisonNeighbor := *neighbor + poisonNeighbor.Meta = map[string]any{"signature": "context poison"} + require.NoError(t, handle.AddBatchChecked([]*graph.Node{¤t, &poisonSibling, &poisonNeighbor}, nil)) + require.NoError(t, handle.SetNodeIdentityReplacements([]string{function.ID})) + require.NoError(t, handle.SetFileMasks([]store_sqlite.FileMask{{FilePath: other, Mode: store_sqlite.OwnershipContext}})) + // Production publication must reject this deliberately staged contradiction. + // Match the existing layer-context defence fixtures: leave it unpublished + // while checking that the layer itself never serves carried context poison. + require.ErrorIs(t, store.PublishPayloadGeneration(t.Context(), handle.ViewGeneration(), 2000), store_sqlite.ErrGenerationMaskIntegrity) + layer, err := NewGenerationLayer(handle) + require.NoError(t, err) + view := graph.NewOverlaidViewWithLayer(store, layer) + var got *graph.FileEditingContextResult + require.NoError(t, graph.WithFileEditingContextReader(t.Context(), view, func(_ context.Context, rows graph.FileEditingContextRows) error { + got = graph.FileEditingContextOf(rows, file, []graph.NodeKind{graph.KindFunction}) + return nil + })) + require.Equal(t, graph.FileEditingContextOf(view, file, []graph.NodeKind{graph.KindFunction}), got) + require.Len(t, got.Defines, 2) + byID := map[string]*graph.Node{} + for _, node := range got.Defines { + byID[node.ID] = node + } + require.Equal(t, current.Meta, byID[function.ID].Meta) + require.Equal(t, sibling.Meta, byID[sibling.ID].Meta) + require.Equal(t, neighbor.Meta, got.CalledBy[0].Meta) + require.Len(t, got.Calls, 1) + require.Equal(t, neighbor.Meta, got.Calls[0].Meta) +} diff --git a/internal/mcp/contract_core_capability_table_test.go b/internal/mcp/contract_core_capability_table_test.go index 88c3131a1..96fd10a49 100644 --- a/internal/mcp/contract_core_capability_table_test.go +++ b/internal/mcp/contract_core_capability_table_test.go @@ -175,6 +175,8 @@ func contractCoreCapabilityTable() []contractCoreCapRow { {"FileEditingContext", capOf[graph.FileEditingContext], capFiltered, "the store reads raw imports and calls"}, // Deliberate drops. + {"FileEditingContextRows", capOf[graph.FileEditingContextRows], capWrapper, "raw adjacency; the contextual helper recomputes through filtered rows"}, + {"FileEditingContextReadScope", capOf[graph.FileEditingContextReadScope], capDropped, "raw scoped rows; resolved only by the filtered editing-context helper"}, {"FilteredBFSAdmissionReader", capOf[graph.FilteredBFSAdmissionReader], capDropped, "private scoped admission adapter supplies exact wrapper exclusions"}, {"FilteredCallAncestorReader", capOf[graph.FilteredCallAncestorReader], capDropped, "private candidate coverage adapter supplies this wrapper's exact exclusions"}, {"BFSCapable", capOf[graph.BFSCapable], capWrapper, "canonical hops recomputed over selected filtered adjacency; engine admission remains the Go contract"}, @@ -421,6 +423,7 @@ func contractCoreNonStoreInterfaces() []contractCoreCapName { {"ConstantValueReadStatus", capOf[graph.ConstantValueReadStatus]}, {"ContractOwnerScalarInvalidator", capOf[graph.ContractOwnerScalarInvalidator]}, {"EdgeEndpointProvider", capOf[graph.EdgeEndpointProvider]}, + {"FileEditingContextLayerScope", capOf[graph.FileEditingContextLayerScope]}, {"FileIndexFailureLayerReader", capOf[graph.FileIndexFailureLayerReader]}, {"FileNodeReadCounter", capOf[graph.FileNodeReadCounter]}, {"GenerationLayerIdentity", capOf[graph.GenerationLayerIdentity]}, diff --git a/internal/mcp/contract_core_editing_context.go b/internal/mcp/contract_core_editing_context.go index 2b28edcb1..be7907f34 100644 --- a/internal/mcp/contract_core_editing_context.go +++ b/internal/mcp/contract_core_editing_context.go @@ -1,6 +1,11 @@ package mcp -import "github.com/zzet/gortex/internal/graph" +import ( + "context" + "time" + + "github.com/zzet/gortex/internal/graph" +) // contractCoreEditingContext serves the get_editing_context fast path through // a contract-core wrapper. The selected reader's own FileEditingContext reads @@ -27,3 +32,87 @@ func fileEditingContextFor(reader graph.Reader) (graph.FileEditingContext, bool) fc, ok := reader.(graph.FileEditingContext) return fc, ok } + +// fileEditingContextContext performs the entire bundle inside the selected +// reader's checked scope. A refused or canceled scope never falls through to +// errorless per-symbol reads. The RepoView lease remains owned by the request. +func fileEditingContextContext(ctx context.Context, reader graph.Reader, path string, kinds []graph.NodeKind) (*graph.FileEditingContextResult, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Millisecond) + defer cancel() + selected := reader + var core *contractCoreEdges + if wrapped, ok := reader.(contractCoreWrapped); ok { + core = wrapped.contractCore() + selected = core.Reader + } + var result *graph.FileEditingContextResult + err := graph.WithFileEditingContextReader(ctx, selected, func(_ context.Context, rows graph.FileEditingContextRows) error { + if core != nil { + rows = &editingContextCoreRows{FileEditingContextRows: rows, core: core} + } + result = graph.FileEditingContextOf(rows, path, kinds) + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +type editingContextCoreRows struct { + graph.FileEditingContextRows + core *contractCoreEdges +} + +func (r *editingContextCoreRows) filter(rows []*graph.Edge) []*graph.Edge { + var ids []string + for _, edge := range rows { + if r.core.visible(edge) { + ids = append(ids, edge.From, edge.To) + } + } + var nodes map[string]*graph.Node + if r.core.contractIDs == nil { + nodes = r.GetNodesByIDs(ids) + } + owned := func(id string) bool { + n := nodes[id] + return n != nil && (n.Kind == graph.KindContract || n.Kind == graph.KindContractBridge || n.Kind == graph.KindConfigKey) + } + var out []*graph.Edge + for _, edge := range rows { + if r.core.visible(edge) && !owned(edge.From) && !owned(edge.To) { + out = append(out, edge) + } + } + return out +} +func (r *editingContextCoreRows) GetOutEdges(id string) []*graph.Edge { + return r.filter(r.FileEditingContextRows.GetOutEdges(id)) +} +func (r *editingContextCoreRows) filterBatch(rows map[string][]*graph.Edge) map[string][]*graph.Edge { + var all []*graph.Edge + for _, edges := range rows { + all = append(all, edges...) + } + visible := make(map[*graph.Edge]bool) + for _, edge := range r.filter(all) { + visible[edge] = true + } + out := make(map[string][]*graph.Edge, len(rows)) + for id, edges := range rows { + for _, edge := range edges { + if visible[edge] { + out[id] = append(out[id], edge) + } + } + } + return out +} + +func (r *editingContextCoreRows) GetOutEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { + return r.filterBatch(r.FileEditingContextRows.GetOutEdgesByNodeIDs(ids)) +} +func (r *editingContextCoreRows) GetInEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { + return r.filterBatch(r.FileEditingContextRows.GetInEdgesByNodeIDs(ids)) +} diff --git a/internal/mcp/contract_core_editing_context_context_test.go b/internal/mcp/contract_core_editing_context_context_test.go new file mode 100644 index 000000000..aa55db2fa --- /dev/null +++ b/internal/mcp/contract_core_editing_context_context_test.go @@ -0,0 +1,47 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +type editingContextStalledScope struct{ graph.Reader } + +func (s editingContextStalledScope) WithFileEditingContextReader(ctx context.Context, _ func(context.Context, graph.FileEditingContextRows) error) error { + <-ctx.Done() + return ctx.Err() +} + +type editingContextPartialScope struct{ *graph.Graph } + +func (s editingContextPartialScope) WithFileEditingContextReader(ctx context.Context, fn func(context.Context, graph.FileEditingContextRows) error) error { + if err := fn(ctx, s.Graph); err != nil { + return err + } + return graph.ErrContractProjectionLimit +} +func TestEditingContextBundleDeadlineAndPartialRefusal(t *testing.T) { + got, err := fileEditingContextContext(t.Context(), editingContextStalledScope{}, "a.go", nil) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, got) + g := graph.New() + g.AddNode(&graph.Node{ID: "a.go::f", Kind: graph.KindFunction, FilePath: "a.go"}) + got, err = fileEditingContextContext(t.Context(), editingContextPartialScope{g}, "a.go", nil) + require.ErrorIs(t, err, graph.ErrContractProjectionLimit) + require.Nil(t, got, "refused complete-looking rows must not escape") + got, err = fileEditingContextContext(t.Context(), struct{ graph.Reader }{g}, "a.go", nil) + require.ErrorIs(t, err, graph.ErrFileEditingContextUnsupported) + require.Nil(t, got, "no legacy bundle or engine fallback") +} +func TestEditingContextBundleContractFilterFullParity(t *testing.T) { + store := newContractCoreEditingStore(t) + wrapped := newContractCoreEdges(store, t.Context(), nil) + kinds := []graph.NodeKind{graph.KindFunction, graph.KindMethod} + want := graph.FileEditingContextOf(wrapped, "repo/a.go", kinds) + got, err := fileEditingContextContext(t.Context(), wrapped, "repo/a.go", kinds) + require.NoError(t, err) + require.Equal(t, want, got) +} diff --git a/internal/mcp/contract_core_editing_context_test.go b/internal/mcp/contract_core_editing_context_test.go index b99ad456b..283acf3dd 100644 --- a/internal/mcp/contract_core_editing_context_test.go +++ b/internal/mcp/contract_core_editing_context_test.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "fmt" "path/filepath" "testing" @@ -102,6 +103,23 @@ func (s *contractCoreEditingSpy) GetInEdgesByNodeIDs(ids []string) map[string][] return s.Store.GetInEdgesByNodeIDs(ids) } +// The scope spy observes the same batched assembly inside its borrowed reader. +func (s *contractCoreEditingSpy) WithFileEditingContextReader(ctx context.Context, fn func(context.Context, graph.FileEditingContextRows) error) error { + return s.Store.WithFileEditingContextReader(ctx, func(ctx context.Context, rows graph.FileEditingContextRows) error { + return fn(ctx, &contractCoreEditingRowsSpy{FileEditingContextRows: rows, spy: s}) + }) +} + +type contractCoreEditingRowsSpy struct { + graph.FileEditingContextRows + spy *contractCoreEditingSpy +} + +func (r *contractCoreEditingRowsSpy) GetInEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { + r.spy.inEdgeBatches++ + return r.FileEditingContextRows.GetInEdgesByNodeIDs(ids) +} + // get_editing_context on a primary session takes the fast path under the // runtime and shows only what the filtered adjacency shows; without the // runtime the store answers raw. diff --git a/internal/mcp/ensure_fresh_self_heal_test.go b/internal/mcp/ensure_fresh_self_heal_test.go index 8df3c63f5..3f2ab95d2 100644 --- a/internal/mcp/ensure_fresh_self_heal_test.go +++ b/internal/mcp/ensure_fresh_self_heal_test.go @@ -20,12 +20,8 @@ import ( "github.com/zzet/gortex/internal/search" ) -// TestEnsureFresh_MultiRepoSelfHealsStaleFile is the regression test for the -// stale-read class: in multi-repo mode ensureFresh used to return early and do -// nothing, so a file changed on disk since indexing kept serving its old body -// through the index-backed read tools. It must now route the path to its owning -// per-repo indexer, detect the drift, and re-index — leaving the graph current. -func TestEnsureFresh_MultiRepoSelfHealsStaleFile(t *testing.T) { +// A drifted read is current in its own view without advancing either base. +func TestDriftedMultiRepoReadProjectsWithoutPublishingBase(t *testing.T) { repoA := setupMiniRepo(t, "repo-a") repoB := setupMiniRepo(t, "repo-b") @@ -67,22 +63,21 @@ func TestEnsureFresh_MultiRepoSelfHealsStaleFile(t *testing.T) { bumpFile(t, filepath.Join(repoA, "main.go"), "package main\n\nfunc Hello() {}\n\nfunc HelloAgain() {}\n") - refreshed := srv.ensureFresh([]string{"repo-a/main.go"}) - assert.Equal(t, []string{"repo-a/main.go"}, refreshed, - "multi-repo ensureFresh must re-index the drifted file (pre-fix it returned nil)") - assert.NotEmpty(t, g.FindNodesByName("HelloAgain"), - "the graph reflects the new on-disk content after self-heal") + t.Cleanup(func() { srv.DrainBackground(); require.NoError(t, mi.Close(context.Background())) }) + ctx, err := srv.ensureProjected(t.Context(), []string{"repo-a/main.go"}) + require.NoError(t, err) + require.NotNil(t, srv.readerFor(ctx).GetNode("repo-a/main.go::HelloAgain")) + assert.Empty(t, g.FindNodesByName("HelloAgain"), "read must not publish the shared base") + assert.True(t, mi.GetIndexer("repo-a").IsTrackedStale("main.go"), "read must not advance indexed file identity") + assert.Equal(t, g.GetFileNodes("repo-b/main.go"), srv.readerFor(ctx).GetFileNodes("repo-b/main.go"), "unrelated repository is unchanged") + next, err := srv.ensureProjected(t.Context(), []string{"repo-a/main.go"}) + require.NoError(t, err) + require.Same(t, requestProjectionLayers(ctx)[0], requestProjectionLayers(next)[0], "reuse the same checked immutable file layer") - // A follow-up call is a no-op: the recorded mtime advanced, so the file is - // no longer classified stale and is not re-indexed again. - assert.Empty(t, srv.ensureFresh([]string{"repo-a/main.go"}), - "an already-fresh file is not re-indexed a second time") } -// TestEnsureFresh_SingleRepoSelfHealsStaleFile covers the single-repo path with -// no active watcher: the on-read freshness check still re-indexes a drifted -// file so the graph does not serve a stale body. -func TestEnsureFresh_SingleRepoSelfHealsStaleFile(t *testing.T) { +// A standalone server uses the same projection path, without a watcher. +func TestDriftedStandaloneReadProjectsWithoutPublishingBase(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc Hello() {}\n"), 0o644)) @@ -94,15 +89,17 @@ func TestEnsureFresh_SingleRepoSelfHealsStaleFile(t *testing.T) { require.NoError(t, err) eng := query.NewEngine(g) - srv := NewServer(eng, g, idx, nil, zap.NewNop(), nil) // watcher nil → on-read refresh active + srv := NewServer(eng, g, idx, nil, zap.NewNop(), nil) // no watcher owns publication require.Empty(t, g.FindNodesByName("HelloAgain")) bumpFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc Hello() {}\n\nfunc HelloAgain() {}\n") - refreshed := srv.ensureFresh([]string{"main.go"}) - assert.Equal(t, []string{"main.go"}, refreshed) - assert.NotEmpty(t, g.FindNodesByName("HelloAgain"), - "single-repo self-heal re-indexed the drifted file") + ctx, err := srv.ensureProjected(t.Context(), []string{"main.go"}) + require.NoError(t, err) + require.NotNil(t, srv.readerFor(ctx).GetNode("main.go::HelloAgain")) + assert.Empty(t, g.FindNodesByName("HelloAgain")) + assert.True(t, idx.IsTrackedStale("main.go")) + } diff --git a/internal/mcp/facade_tools.go b/internal/mcp/facade_tools.go index 706d10ebb..ad7395d7f 100644 --- a/internal/mcp/facade_tools.go +++ b/internal/mcp/facade_tools.go @@ -1172,6 +1172,7 @@ func (s *Server) invokeFacadeSpec(ctx context.Context, req mcpgo.CallToolRequest if refusal := s.awaitWatchedFreshness(ctx, spec.Legacy, forwarded, takeRequestFreshness(&forwarded)); refusal != nil { return refusal, nil } + ctx = withProjectedReadToolFacts(ctx, spec.Legacy) ctx = s.captureToolFreshness(ctx) ctx, forwarded = s.reserveToolFreshnessBudget(ctx, spec.Legacy, forwarded) result, err = legacy.handler(ctx, forwarded) diff --git a/internal/mcp/freshness_idle_budget_contract_test.go b/internal/mcp/freshness_idle_budget_contract_test.go index e9f065e63..42adad367 100644 --- a/internal/mcp/freshness_idle_budget_contract_test.go +++ b/internal/mcp/freshness_idle_budget_contract_test.go @@ -105,7 +105,7 @@ func TestFreshnessResponseContractFinalPayloadHonorsByteBudget(t *testing.T) { 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"]) + require.Equal(t, "relations", fields["freshness_scope"]) } else { require.True(t, strings.HasPrefix(text, "relations: pending (1 files)\n"), "budget must preserve the relations label: %s", text) diff --git a/internal/mcp/freshness_response_contract_test.go b/internal/mcp/freshness_response_contract_test.go index a954c2d1e..127f158a7 100644 --- a/internal/mcp/freshness_response_contract_test.go +++ b/internal/mcp/freshness_response_contract_test.go @@ -17,30 +17,34 @@ func TestFreshnessResponseContractNormalizesFileReadPaths(t *testing.T) { 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, - } + 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"]) - }) + for _, scope := range []string{"", "file"} { + t.Run(target+"/scope_"+scope, func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Name = tool + arguments := map[string]any{"path": target, "format": "json"} + expectedScope := "relations" + if tool == "read_file" { + expectedScope = "file" + } + if scope != "" { + arguments["freshness_scope"] = scope + expectedScope = scope + } + req.Params.Arguments = arguments + res, err := s.wrapToolHandler(handlers[tool])(t.Context(), req) + require.NoError(t, err) + require.False(t, res.IsError, resultText(res)) + freshness, ok := resultJSON(t, res)["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, expectedScope, freshness["freshness_scope"]) + require.EqualValues(t, 1, freshness["pending_file_count"]) + require.Equal(t, []any{"target.go"}, freshness["pending_files"]) + }) + } } }) } diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 77db98f83..56c2f56ce 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -322,7 +322,7 @@ func targetRepoFileRaw(toolName string, req mcp.CallToolRequest) string { switch toolName { case "read_file", "get_file_summary", "get_editing_context": raw = req.GetString("path", "") - case "get_symbol_source", "get_symbol": + case "get_symbol_source", "get_symbol", "get_cfg": id := req.GetString("id", "") if i := strings.Index(id, "::"); i >= 0 { raw = id[:i] @@ -490,9 +490,9 @@ func collectGraphFilePaths(v any) []string { return out } -// decorateResultWithFreshness merges freshness into JSON-object responses -// and renders an in-band label for other output modes. Freshness-free -// responses pass through unchanged. +// decorateResultWithFreshness attaches the freshness rider under the +// "freshness" key for JSON objects and uses the shared line renderer for +// compact formats. An absent rider leaves the response byte-identical. func decorateResultWithFreshness(res *mcp.CallToolResult, rider map[string]any) *mcp.CallToolResult { if len(rider) == 0 { return res @@ -522,6 +522,10 @@ func decorateResultWithFreshness(res *mcp.CallToolResult, rider map[string]any) func renderFreshnessLine(res *mcp.CallToolResult, fields map[string]any) *mcp.CallToolResult { state, _ := fields["graph_freshness"].(string) scope, _ := fields["freshness_scope"].(string) + projected := fields["file_view"] == "projected" && fields["readback_exact"] == true + if state == "" && projected { + state, scope = "projected", "file" + } if state == "" { if fields["stale"] != true && fields["missing"] != true && fields["index_frozen"] != true { return res @@ -539,6 +543,9 @@ func renderFreshnessLine(res *mcp.CallToolResult, fields map[string]any) *mcp.Ca count = 1 } line := fmt.Sprintf("%s: %s (%v files)", scope, state, count) + if state == "projected" { + line = "file: projected (readback exact)" + } body, ok := singleTextContent(res) if !ok || strings.HasPrefix(body, line+"\n") { return res @@ -560,6 +567,17 @@ func renderFreshnessLine(res *mcp.CallToolResult, fields map[string]any) *mcp.Ca // 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} + var projectedFiles []string + if len(files) > 0 { + label.projected = true + for _, file := range files { + projected := s.hasRequestProjectionFact(ctx, file) + label.projected = label.projected && projected + if scope != "file" && projected { + projectedFiles = append(projectedFiles, file) + } + } + } paths, watched := s.canonicalTierPaths(ctx) var receipts []*mutationReceipt if !watched { @@ -570,7 +588,7 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st if len(snapshots) > 0 { snapshot, _ = ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot) } - gapCount := len(paths) + len(receipts) + len(snapshot.paths) + gapCount := len(paths) + len(receipts) + len(snapshot.paths) + len(projectedFiles) if len(snapshots) > 0 { gapCount += len(snapshots[0]) } @@ -595,11 +613,20 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st if len(ids)+len(files) > 0 && !inScope(path) { return } + if scope == "file" && s.hasRequestProjectionFact(ctx, path) { + return + } key := s.repoRelative(path) if prior, exists := states[key]; !exists || generation >= prior.generation { states[key] = pathState{state, generation} } } + // Exact local projection is independent of background relation/derived + // publication, including external writes with no watcher receipt. Keep the + // request's observed gap conservative instead of certifying graph freshness. + for _, file := range projectedFiles { + put(file, "pending", 0) + } putTiers := func(path string, tiers indexer.MutationTierState) { at := tiers.RelationsAt switch scope { @@ -695,13 +722,19 @@ func (s *Server) captureToolFreshness(ctx context.Context) context.Context { } return ctx } + ctx = withRequestProjectionFacts(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) { + switch name { + case "get_file_summary", "get_editing_context": return "relations", nil, []string{file} + case "get_symbol": + if req.GetString("detail", "brief") == "full" { + return "relations", nil, []string{file} + } } return "file", nil, []string{file} } @@ -749,7 +782,16 @@ func freshnessTargets(name string, req mcp.CallToolRequest) (scope string, ids, } func (s *Server) decorateToolFreshness(ctx context.Context, name string, req mcp.CallToolRequest, res *mcp.CallToolResult) *mcp.CallToolResult { + // The leaf may have read before a newer physical execution replaced its + // borrowed source view. Refuse the entire read before labelling/rendering; + // effectful responses retain their actual disk effects and receipts. + if res != nil && !res.IsError && lateExactnessRefusalIsSafe(name) { + if err := validateRequestProjections(ctx); err != nil { + return mcp.NewToolResultError(err.Error()) + } + } scope, ids, files := freshnessTargets(name, req) + projected := false var label relationFreshness if scope != "" { if selected := takeRequestFreshness(&req).scope; selected != "" { @@ -759,7 +801,8 @@ func (s *Server) decorateToolFreshness(ctx context.Context, name string, req mcp if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { label = s.freshnessFor(ctx, scope, ids, files, snapshot.receipts) } - if label.state != "" { + projected = label.projected + if label.state != "" || projected { fields := make(map[string]any) label.apply(fields) fields["freshness_scope"] = scope @@ -767,8 +810,10 @@ func (s *Server) decorateToolFreshness(ctx context.Context, name string, req mcp } } beforeFileRiders := res - if rider := s.freshnessRiderFor(name, req); rider != nil { - res = decorateResultWithFreshness(res, rider) + if !projected { + if rider := s.freshnessRiderFor(name, req); rider != nil { + res = decorateResultWithFreshness(res, rider) + } } if isFreshnessListTool(name) { res = s.decorateListResultWithFreshness(res) @@ -1034,6 +1079,10 @@ func (s *Server) awaitWatchedFreshness(ctx context.Context, name string, req mcp } func (f relationFreshness) apply(result map[string]any) { + if f.projected { + result["file_view"] = "projected" + result["readback_exact"] = true + } if f.state == "" { return } @@ -1054,7 +1103,7 @@ func (f relationFreshness) apply(result map[string]any) { } func (f relationFreshness) decorate(res *mcp.CallToolResult) *mcp.CallToolResult { - if f.state == "" || res == nil || res.IsError { + if f.state == "" && !f.projected || res == nil || res.IsError { return res } body, ok := singleTextContent(res) diff --git a/internal/mcp/move_file_observation.go b/internal/mcp/move_file_observation.go index d8fe83a0c..114c69e0f 100644 --- a/internal/mcp/move_file_observation.go +++ b/internal/mcp/move_file_observation.go @@ -61,7 +61,7 @@ func (s *Server) beginMoveObservation(ctx context.Context, sourceID, targetID, s moveSession := SessionIDFromContext(ctx) out := &moveObservation{server: s, id: "move-" + strconv.FormatUint(ack.id, 10), records: make(map[string]*mutationCommitRecord, 2)} for _, file := range []struct{ abs, rel, role string }{{sourceAbs, sourceRel, "source"}, {targetAbs, targetRel, "target"}} { - record := &mutationCommitRecord{kind: moveFileObservationCommit, owner: s, id: "move-file-" + strconv.FormatUint(mutationCommitSequence.Add(1), 10), tool: "move_symbol", key: out.id, absPath: file.abs, relPath: file.rel, startedAt: ack.entered, acknowledgement: ack, disk: mutationDiskInFlight, checkoutID: checkout, checkoutIncarnation: incarnation, moveFile: &moveFileObservation{operationID: out.id, sourceID: sourceID, targetID: targetID, role: file.role, moveSession: moveSession, moveRequestKey: moveRequestKey, writeVerdict: "pending", directState: "not_started", readState: "not_started"}} + record := &mutationCommitRecord{kind: moveFileObservationCommit, owner: s, id: "move-file-" + strconv.FormatUint(mutationCommitSequence.Add(1), 10), tool: "move_symbol", key: out.id, absPath: file.abs, relPath: file.rel, startedAt: ack.entered, acknowledgement: ack, disk: mutationDiskInFlight, checkoutID: checkout, checkoutIncarnation: incarnation, moveFile: &moveFileObservation{operationID: out.id, sourceID: sourceID, targetID: targetID, role: file.role, moveSession: moveSession, moveRequestKey: moveRequestKey, writeVerdict: "pending", directState: "not_started", readState: "not_applicable_projected_reads"}} s.mutationCommits.put(record) out.records[file.abs] = record out.refs = append(out.refs, moveFileObservationReference{Path: file.rel, Role: file.role, Receipt: record.id}) diff --git a/internal/mcp/move_file_observation_test.go b/internal/mcp/move_file_observation_test.go index 29e3436a3..6e2efccdc 100644 --- a/internal/mcp/move_file_observation_test.go +++ b/internal/mcp/move_file_observation_test.go @@ -382,14 +382,12 @@ func TestMoveOriginalDirectAdmissionCannotBeReissued(t *testing.T) { require.Equal(t, before["direct_reconcile"], after["direct_reconcile"], "completed original owner changed after re-admission attempt") } -// The source and target are written before either direct reconciliation. -// An independent public reader may therefore publish the target first. The -// later direct no-op cannot donate a new acceptance window to that write. -func TestMovePublicReadPublishesTargetBeforeDirectAdmission(t *testing.T) { +// A projected read serves current bytes without borrowing publication credit. +func TestMovePublicReadProjectsBeforeDirectAdmission(t *testing.T) { s, observation, ctx, _, _, target := moveObservationExecutionFixture(t) idx := s.multiIndexer.GetIndexer("fixture") const movedID = "fixture/target.go::Moved" - require.Nil(t, s.engineFor(t.Context()).GetSymbol(movedID), "fixture must require an actual target publication") + require.Nil(t, s.engineFor(t.Context()).GetSymbol(movedID)) root, ok := s.multiIndexer.RepoRoot("fixture") require.True(t, ok) readerCtx := WithSessionCWD(WithSessionID(ctx, "move-independent-reader"), root) @@ -399,16 +397,16 @@ func TestMovePublicReadPublishesTargetBeforeDirectAdmission(t *testing.T) { require.NoError(t, err) require.False(t, result.IsError) require.Contains(t, unmarshalResult(t, result)["source"], "func Moved()") - require.NotNil(t, s.engineFor(t.Context()).GetSymbol(movedID), "actual public read did not apply the target") - // Keep real executor proof: unchanged direct work cannot be relabeled as - // applied acceptance, regardless of how the earlier reader is retained. - later := captureMoveActualExecution(t, s, target, observation.observer(ctx, target, idx)) - require.False(t, later.AcceptedFor(idx, observation.records[target].newSHA), "fixture must exercise the later no-op, not another actual apply") + require.Nil(t, s.engineFor(t.Context()).GetSymbol(movedID), "read must not publish target into shared base") + before, _ := s.moveFileObservationPayload(t.Context(), observation.records[target].id) + require.Equal(t, "not_applicable_projected_reads", before["read_assisted_reconcile_state"]) + require.NotContains(t, before, "read_assisted_reconcile", "projection is not a numeric publication window") + actual := captureMoveActualExecution(t, s, target, observation.observer(ctx, target, idx)) + require.True(t, actual.AcceptedFor(idx, observation.records[target].newSHA), "only the later direct execution publishes target") status, _ := s.moveFileObservationPayload(t.Context(), observation.records[target].id) - require.Equal(t, "observed", status["read_assisted_reconcile_state"], "the actual earlier public publisher must retain its own independent window") - require.Contains(t, status, "read_assisted_reconcile") - require.NotEqual(t, "observed", status["direct_reconcile_state"], "read-assisted publication must not relabel the later direct no-op") - require.NotContains(t, status, "direct_reconcile") + require.Equal(t, "observed", status["direct_reconcile_state"]) + require.Equal(t, "not_applicable_projected_reads", status["read_assisted_reconcile_state"]) + require.NotContains(t, status, "read_assisted_reconcile") } func TestMoveFileWriteTimingPreservesEqualClockReadings(t *testing.T) { diff --git a/internal/mcp/move_read_assisted_observation_test.go b/internal/mcp/move_read_assisted_observation_test.go index 7ebbba2e9..561e58313 100644 --- a/internal/mcp/move_read_assisted_observation_test.go +++ b/internal/mcp/move_read_assisted_observation_test.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "os" - "runtime" - "strings" "sync" "testing" "time" @@ -91,13 +89,15 @@ func TestMoveReadAssistedPendingVerdictAndUnknownOwnerRemainUnavailable(t *testi require.Equal(t, "unavailable_move_write_verdict", after["read_assisted_reconcile_state"], "later final verdict cannot manufacture a callback window") } -func TestMoveActualCoalescedPublicReaderAndDirectKeepBothCausesOneExecution(t *testing.T) { +// The reader must not wait for or join the pending direct executor. Legacy +// observer fixtures above retain their historical identity/competitor fences. +func TestProjectedReadDoesNotJoinOrWaitForMoveDirectExecution(t *testing.T) { s, move, ctx, _, _, target := moveObservationExecutionFixture(t) root, ok := s.multiIndexer.RepoRoot("fixture") require.True(t, ok) entered, resume := make(chan struct{}), make(chan struct{}) - var releaseOnce sync.Once - release := func() { releaseOnce.Do(func() { close(resume) }) } + var once sync.Once + release := func() { once.Do(func() { close(resume) }) } gateDone := make(chan error, 1) go func() { gateDone <- s.multiIndexer.RunRepositoryTopologyBatch(t.Context(), func(context.Context) error { close(entered); <-resume; return nil }) @@ -115,94 +115,63 @@ func TestMoveActualCoalescedPublicReaderAndDirectKeepBothCausesOneExecution(t *t select { case <-gateDone: case <-time.After(5 * time.Second): - t.Error("coalescing gate did not join") + t.Error("topology gate did not join") } } if readerStarted && !readerJoined { select { case <-readerDone: case <-time.After(5 * time.Second): - t.Error("actual public reader did not join") + t.Error("public reader did not join") } } if directStarted && !directJoined { select { case <-directDone: case <-time.After(5 * time.Second): - t.Error("actual direct caller did not join") + t.Error("direct publisher did not join") } } }) select { case <-entered: case <-time.After(5 * time.Second): - t.Fatal("existing topology gate was not held") + t.Fatal("topology gate was not held") } + directStarted = true + go func() { directDone <- move.reindex(ctx, target) }() readerStarted = true go func() { request := mcpgo.CallToolRequest{} request.Params.Arguments = map[string]any{"id": "fixture/target.go::Moved", "context_lines": 0} - result, err := s.handleGetSymbolSource(WithSessionCWD(WithSessionID(ctx, "actual-coalesced-reader"), root), request) + result, err := s.handleGetSymbolSource(WithSessionCWD(WithSessionID(ctx, "independent-projected-reader"), root), request) readerDone <- readerResult{result, err} }() - directStarted = true - go func() { directDone <- move.reindex(ctx, target) }() - // The existing gate blocks drain before it takes pending paths. Observe the - // two real callers parked after coordinator admission, not just goroutine - // launch or observer construction; then release one genuinely coalesced batch. + select { + case read := <-readerDone: + readerJoined = true + require.NoError(t, read.err) + require.False(t, read.result.IsError, "%v", read.result.Content) + require.Contains(t, unmarshalResult(t, read.result)["source"], "func Moved()") + case <-time.After(2 * time.Second): + t.Fatal("read waited for the held mutation lane") + } + require.Nil(t, s.engineFor(t.Context()).GetSymbol("fixture/target.go::Moved"), "read did not publish base") require.Eventually(t, func() bool { record := move.records[target] record.mu.RLock() - pending := record.moveFile.directState == "pending_observer" && record.moveFile.readState == "pending_observer" - record.mu.RUnlock() - if !pending { - return false - } - stack := make([]byte, 128<<10) - n := runtime.Stack(stack, true) - return strings.Count(string(stack[:n]), "(*repositoryMutationCoordinator).reconcileObserved(") >= 2 - }, 5*time.Second, time.Millisecond) + defer record.mu.RUnlock() + return record.moveFile.directState == "pending_observer" + }, time.Second, time.Millisecond) release() - select { - case err := <-gateDone: - gateJoined = true - require.NoError(t, err) - case <-time.After(5 * time.Second): - t.Fatal("gate did not finish") - } - select { - case result := <-readerDone: - readerJoined = true - require.NoError(t, result.err) - require.False(t, result.result.IsError) - case <-time.After(5 * time.Second): - t.Fatal("public read did not finish") - } - select { - case success := <-directDone: - directJoined = true - require.True(t, success) - case <-time.After(5 * time.Second): - t.Fatal("direct reconcile did not finish") - } + require.NoError(t, <-gateDone) + gateJoined = true + require.True(t, <-directDone) + directJoined = true status, _ := s.moveFileObservationPayload(t.Context(), move.records[target].id) require.Equal(t, "observed", status["direct_reconcile_state"]) - require.Equal(t, "observed", status["read_assisted_reconcile_state"]) + require.Equal(t, "not_applicable_projected_reads", status["read_assisted_reconcile_state"]) + require.NotContains(t, status, "read_assisted_reconcile") direct := status["direct_reconcile"].(map[string]any) - repair := status["read_assisted_reconcile"].(map[string]any) - for _, key := range []string{"owner_generation", "window_ns", "accepted_blob_sha", "writer_return_to_acceptance_ns", "started_from_ack_ns", "finished_from_ack_ns", "accepted_from_start_ns", "reconcile_causes"} { - require.Equal(t, direct[key], repair[key], "different executed %s", key) - } - require.Equal(t, "actual-coalesced-reader", repair["request_cause"].(mutationReadRepairCause).Session) - require.NotEqual(t, repair["request_cause"], direct["request_cause"]) - require.Len(t, repair["reconcile_causes"], 2) - directPhases := direct["phase_accounting"].(map[string]any) - repairPhases := repair["phase_accounting"].(map[string]any) - require.Equal(t, "move_direct_reconcile", directPhases["scope"]) - require.Equal(t, "move_read_assisted_reconcile", repairPhases["scope"]) - for key, value := range directPhases { - if key != "scope" { - require.Equal(t, value, repairPhases[key], "different partition %s", key) - } - } + require.Len(t, direct["reconcile_causes"], 1, "the projection read cannot invent a second physical execution/cause") } diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index f47f35ca8..677772b61 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -356,6 +356,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay ctx = withContractCoreReadErrors(ctx) } indexer.StampPublicationPhase(ctx, indexer.PublicationHandlerStarted) + ctx = withProjectedReadToolFacts(ctx, req.Params.Name) ctx, releaseExplicitFreshness := s.captureExplicitFreshness(ctx, req) if releaseExplicitFreshness != nil { defer releaseExplicitFreshness() diff --git a/internal/mcp/overlay_view.go b/internal/mcp/overlay_view.go index c10dd2b76..5a6db97b2 100644 --- a/internal/mcp/overlay_view.go +++ b/internal/mcp/overlay_view.go @@ -185,10 +185,14 @@ func (s *Server) readerFor(ctx context.Context) graph.Reader { // play, and what a buffer overlay composes on top of: the routed checkout // view when the request resolved to one, the indexed corpus otherwise. func (s *Server) requestBaseReader(ctx context.Context) graph.Reader { + var reader graph.Reader = s.graph if view := requestViewFromContext(ctx); view.routed() { - return view.reader + reader = view.reader } - return s.graph + for _, layer := range requestProjectionLayers(ctx) { + reader = graph.NewOverlaidViewWithLayer(reader, layer) + } + return reader } // nodeGetterFor returns the request-scoped node lookup: the session @@ -243,6 +247,9 @@ func (s *Server) engineFor(ctx context.Context) *query.Engine { // shared engine untouched (zero overhead); its handlers pass their // context through QueryOptions.Context instead. view := requestViewFromContext(ctx) + if len(requestProjectionLayers(ctx)) != 0 { + return s.engine.WithComposedView(s.readerFor(ctx), view.candidateLayers(), ctx, view.excludesBaseCorpus()).WithLayers(s.requestLayers(ctx)) + } if v := OverlayViewFromContext(ctx); v != nil { return s.engine.WithViewLayersContext(v, view.candidateLayers(), ctx).WithLayers(s.requestLayers(ctx)) } diff --git a/internal/mcp/pending_projection.go b/internal/mcp/pending_projection.go index f8c257ca4..1415bbf83 100644 --- a/internal/mcp/pending_projection.go +++ b/internal/mcp/pending_projection.go @@ -221,6 +221,32 @@ func (p *pendingProjections) publish(e *projectionEntry) error { } p.mu.Lock() defer p.mu.Unlock() + if prev := p.snap.Load().entries[e.key]; prev != nil && prev.commitSeq > e.commitSeq { + p.refused[projectionSuperseded]++ + return projectionSuperseded + } + return p.publishLocked(e) +} + +// Read-through has no physical sequence to borrow. It may replace exactly the +// immutable entry it checked, including an old physical entry, after checking +// the current bytes again. A new/retired pointer cannot authorize that replace. +func (p *pendingProjections) publishExpected(e, expected *projectionEntry) error { + if !p.enabled { + return projectionDisabled + } + p.mu.Lock() + defer p.mu.Unlock() + if e.commitSeq != 0 || p.snap.Load().entries[e.key] != expected { + p.refused[projectionSuperseded]++ + return projectionSuperseded + } + return p.publishLocked(e) +} + +// Callers prove ordering by physical sequence or the exact checked pointer. +// Path-case and global file/byte caps apply equally to both publication paths. +func (p *pendingProjections) publishLocked(e *projectionEntry) error { cur := p.snap.Load() prev := cur.entries[e.key] bytes := cur.bytes + e.size @@ -231,8 +257,6 @@ func (p *pendingProjections) publish(e *projectionEntry) error { 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: @@ -389,6 +413,12 @@ func (p *pendingProjections) retireSelected(store projectionStore, selected map[ caughtUp = append(caughtUp, e) } } + // Checked selected adapters must finish all I/O before dropAll takes mu. + if checked, ok := store.(interface{ validateProjectionRetirement() error }); ok { + if checked.validateProjectionRetirement() != nil { + return 0 + } + } retired := p.dropAll(caughtUp) p.retired.Add(int64(retired)) return retired diff --git a/internal/mcp/projected_read_pinned_request_test.go b/internal/mcp/projected_read_pinned_request_test.go new file mode 100644 index 000000000..0562e205d --- /dev/null +++ b/internal/mcp/projected_read_pinned_request_test.go @@ -0,0 +1,106 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/parser" +) + +func TestEnsureProjectedKeepsAlreadyBorrowedRequestFileVersion(t *testing.T) { + for _, supersession := range []string{"new names", "same bytes new physical owner"} { + t.Run(supersession, func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + original, err := os.ReadFile(path) + require.NoError(t, err) + raw := append(append([]byte(nil), original...), []byte("\nfunc RequestVersionOne() {}\n")...) + require.NoError(t, os.WriteFile(path, raw, 0o644)) + first, err := s.ensureProjected(t.Context(), []string{"main.go"}) + require.NoError(t, err) + firstLayers := requestProjectionLayers(first) + require.Len(t, firstLayers, 1) + require.NotNil(t, s.readerFor(first).GetNode("main.go::RequestVersionOne")) + req := mcplib.CallToolRequest{} + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "main.go", "format": "json"} + before := parser.ParseCount() + stable, err := s.handleGetFileSummary(first, req) + require.NoError(t, err) + require.False(t, stable.IsError, "%v", stable.Content) + require.Equal(t, before, parser.ParseCount(), "a stable borrowed file does not need another parse") + if supersession == "new names" { + require.NoError(t, os.WriteFile(path, append(append([]byte(nil), original...), []byte("\nfunc RequestVersionTwo() {}\n")...), 0o644)) + } else { + held, release, lockErr := acquireMutationPath(t.Context(), path) + require.NoError(t, lockErr) + commit, writeErr := s.commitFileMutation(held, "write_file", "", "", "main.go", path, raw, 0o644) + release() + require.NoError(t, writeErr) + require.Positive(t, commit.commitSeq, "this is an actual unchanged-byte newer physical execution") + } + second, err := s.ensureProjected(t.Context(), []string{"main.go"}) + require.NoError(t, err) + if supersession == "new names" { + require.NotNil(t, s.readerFor(second).GetNode("main.go::RequestVersionTwo")) + require.Nil(t, s.readerFor(second).GetNode("main.go::RequestVersionOne")) + } else { + require.NotNil(t, s.readerFor(second).GetNode("main.go::RequestVersionOne")) + } + before = parser.ParseCount() + refused, err := s.handleGetFileSummary(first, req) + require.NoError(t, err) + require.True(t, refused.IsError, "a superseded candidate/row selection must explicitly refuse, never mix newer rows into it: %v", refused.Content) + require.Contains(t, toolResultText(refused), "superseded") + require.Equal(t, before, parser.ParseCount(), "supersession must be detected by existing pointer ownership") + require.Len(t, requestProjectionLayers(first), 1) + require.Same(t, firstLayers[0], requestProjectionLayers(first)[0]) + require.NotNil(t, s.readerFor(first).GetNode("main.go::RequestVersionOne")) + require.Nil(t, s.graph.GetNode("main.go::RequestVersionTwo")) + }) + } +} + +func TestProjectedReadWrapperRefusesPhysicalSupersessionAfterLeafRead(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + raw, err := os.ReadFile(path) + require.NoError(t, err) + raw = append(raw, []byte("\nfunc SupersededBeforeResponse() {}\n")...) + require.NoError(t, os.WriteFile(path, raw, 0o644)) + req := mcplib.CallToolRequest{} + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "main.go", "format": "json"} + leafRan := false + wrapped := s.wrapToolHandler(func(ctx context.Context, request mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + result, readErr := s.handleGetFileSummary(ctx, request) + require.NoError(t, readErr) + require.False(t, result.IsError, "%v", result.Content) + require.True(t, s.hasRequestProjectionFact(ctx, "main.go")) + leafRan = true + held, release, lockErr := acquireMutationPath(t.Context(), path) + require.NoError(t, lockErr) + commit, writeErr := s.commitFileMutation(held, "write_file", "", "", "main.go", path, raw, 0o644) + release() + require.NoError(t, writeErr) + require.Positive(t, commit.commitSeq) + assert.False(t, s.hasRequestProjectionFact(ctx, "main.go"), "a superseded layer cannot supply current-file freshness to response decoration") + return result, nil + }) + result, err := wrapped(t.Context(), req) + require.NoError(t, err) + require.True(t, leafRan) + require.True(t, result.IsError, "the public wrapper must refuse the complete answer if its selected source owner changed after the leaf read: %v", result.Content) + require.Contains(t, toolResultText(result), "superseded") +} diff --git a/internal/mcp/projected_read_publication_test.go b/internal/mcp/projected_read_publication_test.go new file mode 100644 index 000000000..8147faf26 --- /dev/null +++ b/internal/mcp/projected_read_publication_test.go @@ -0,0 +1,159 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +type heldReadProjectionExtractor struct { + base parser.Extractor + hold atomic.Bool + entered chan struct{} + release chan struct{} +} + +func (e *heldReadProjectionExtractor) Language() string { return "go" } +func (e *heldReadProjectionExtractor) Extensions() []string { return []string{".go"} } +func (e *heldReadProjectionExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult, error) { + if e.hold.CompareAndSwap(true, false) { + close(e.entered) + <-e.release + } + return e.base.Extract(path, raw) +} + +func TestProjectedReadCannotPublishOverNewerReadVersion(t *testing.T) { + for _, mode := range []string{"changed bytes", "same bytes", "newer version already retired"} { + t.Run(mode, func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + extractor := &heldReadProjectionExtractor{base: languages.NewGoExtractor(), entered: make(chan struct{}), release: make(chan struct{})} + registry := parser.NewRegistry() + registry.Register(extractor) + s, root := setupTestServerWithRegistry(t, registry) + path := filepath.Join(root, "main.go") + first := []byte("package main\nfunc AlphaOne() {}\n") + second := []byte("package main\nfunc AlphaTwo() {}\n") + if mode == "same bytes" { + second = first + } + require.NoError(t, os.WriteFile(path, first, 0o644)) + stamp, err := os.Lstat(path) + require.NoError(t, err) + extractor.hold.Store(true) + var release sync.Once + unblock := func() { release.Do(func() { close(extractor.release) }) } + t.Cleanup(unblock) + type result struct { + entry *projectionEntry + err error + } + finished := make(chan result, 1) + go func() { + entry, err := s.projectCurrentTrackedFile(t.Context(), t.Context(), path, "main.go", "main.go") + finished <- result{entry, err} + }() + joined := false + defer func() { + unblock() + if !joined { + select { + case <-finished: + case <-time.After(time.Second): + t.Error("owned read did not join during cleanup") + } + } + }() + select { + case <-extractor.entered: + case <-time.After(time.Second): + t.Fatal("first owned extraction never entered") + } + // In-place equal-length replacement retains inode and restores the + // stamp, exposing a stat-only completion check and nil-pointer ABA. + require.NoError(t, os.WriteFile(path, second, 0o644)) + require.NoError(t, os.Chtimes(path, stamp.ModTime(), stamp.ModTime())) + newer, err := s.projectCurrentTrackedFile(t.Context(), context.Background(), path, "main.go", "main.go") + require.NoError(t, err) + require.NotNil(t, newer) + if mode == "newer version already retired" { + pendingFileProjections.drop(newer) + } + unblock() + var older result + select { + case older = <-finished: + joined = true + case <-time.After(time.Second): + t.Fatal("owned extraction did not finish after release") + } + if mode == "newer version already retired" { + require.Nil(t, liveEntry(pendingFileProjections, path), "retirement must not let old bytes resurrect through a nil-pointer ABA") + } else { + require.Same(t, newer, liveEntry(pendingFileProjections, path), "a completed old read cannot replace the newer immutable snapshot") + } + if mode != "same bytes" { + require.Error(t, older.err, "a parsed source whose bytes moved must refuse even when inode, size and mtime match") + require.Nil(t, older.entry) + } + }) + } +} + +func TestProjectedReadKeepsSuspectInvalidationAfterBoundedReuse(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + raw := []byte("package main\nfunc Changed() {}\n") + require.NoError(t, os.WriteFile(path, raw, 0o644)) + entry, err := s.projectCurrentTrackedFile(t.Context(), t.Context(), path, "main.go", "main.go") + require.NoError(t, err) + require.NotNil(t, entry) + pendingFileProjections.markSuspect(path) + current, err := s.projectCurrentTrackedFile(t.Context(), t.Context(), path, "main.go", "main.go") + require.NoError(t, err) + require.Same(t, entry, current) + require.True(t, entry.suspect.Load(), "a checked request cannot consume a watcher's invalidation for other readers/ACKs") +} + +func TestProjectedReadReplacementDoesNotBorrowPhysicalSequence(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + first := []byte("package main\nfunc AlphaOne() {}\n") + second := []byte("package main\nfunc AlphaTwo() {}\n") + require.NoError(t, os.WriteFile(path, first, 0o644)) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + projection, err := s.indexer.ProjectSource(t.Context(), path, first) + require.NoError(t, err) + // Model an already admitted physical sequence using actual checked bytes. + // The read-through replacement must not mint or borrow that execution ID. + physical := newProjectionEntry(path, s.indexer.RepoPrefix(), projection, first, 7, stamp) + require.NoError(t, pendingFileProjections.publish(physical)) + require.NoError(t, os.WriteFile(path, second, 0o644)) + require.NoError(t, os.Chtimes(path, stamp.info.ModTime(), stamp.info.ModTime())) + current, err := s.projectCurrentTrackedFile(t.Context(), t.Context(), path, "main.go", "main.go") + require.NoError(t, err) + require.NotNil(t, current) + require.NotSame(t, physical, current) + require.Zero(t, current.commitSeq) + require.EqualValues(t, 7, physical.commitSeq) + require.True(t, physical.suspect.Load()) + require.NotNil(t, current.layer.NodeByID("main.go::AlphaTwo")) + require.Nil(t, current.layer.NodeByID("main.go::AlphaOne")) +} diff --git a/internal/mcp/projected_read_runtime_test.go b/internal/mcp/projected_read_runtime_test.go new file mode 100644 index 000000000..713d15bf5 --- /dev/null +++ b/internal/mcp/projected_read_runtime_test.go @@ -0,0 +1,270 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "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" +) + +// A read of a tracked file must see the current file-local symbols while the +// watcher still owns an older graph, without synchronously reindexing it. +func TestProjectedReadFindsNewSymbolWithoutMutatingBase(t *testing.T) { + srv, dir := setupTestServer(t) + srv.SetWatcher(mutationTestWatcher{}) + path := filepath.Join(dir, "main.go") + original, err := os.ReadFile(path) + require.NoError(t, err) + for _, body := range []string{"return 1", "return 2"} { + current := string(original) + "\nfunc Added() int { " + body + " }\n" + require.NoError(t, os.WriteFile(path, []byte(current), 0o644)) + result := callTool(t, srv, "get_symbol_source", map[string]any{"id": "main.go::Added", "context_lines": float64(0), "format": "json"}) + require.False(t, result.IsError, "current symbol must be projected: %v", result.Content) + payload := extractTextResult(t, result) + require.Equal(t, "func Added() int { "+body+" }", payload["source"]) + require.Nil(t, srv.graph.GetNode("main.go::Added"), "read-through must not publish into the shared base graph") + } +} + +func TestProjectedReadAfterPendingToolAckDoesNotPublishBase(t *testing.T) { + srv, dir := setupTestServer(t) + done := make(chan indexer.MutationResult, 1) + t.Cleanup(func() { close(done) }) + srv.SetWatcher(mutationTestWatcher{done: done}) + srv.mutationReindexWait = time.Nanosecond + replacement := "func helper() {}\nfunc AddedByTool() int { return 7 }" + ack := extractTextResult(t, callTool(t, srv, "edit_file", map[string]any{ + "path": filepath.Join(dir, "main.go"), "old_string": "func helper() {}", "new_string": replacement, "format": "json", + })) + require.Equal(t, true, ack["reindex_pending"]) + result := callTool(t, srv, "get_symbol_source", map[string]any{ + "id": "main.go::AddedByTool", "context_lines": float64(0), "format": "json", + }) + require.False(t, result.IsError, "acknowledged source is readable while graph publication is pending: %v", result.Content) + require.Equal(t, "func AddedByTool() int { return 7 }", extractTextResult(t, result)["source"]) + require.Nil(t, srv.graph.GetNode("main.go::AddedByTool"), "a read must not mutate the shared graph") +} + +func TestProjectedReadDeletionHidesOldSymbolWithoutRemovingBase(t *testing.T) { + srv, dir := setupTestServer(t) + srv.SetWatcher(mutationTestWatcher{}) + require.NotNil(t, srv.graph.GetNode("main.go::helper"), "prove the indexed symbol exists") + require.NoError(t, os.Remove(filepath.Join(dir, "main.go"))) + result := callTool(t, srv, "get_symbol", map[string]any{"id": "main.go::helper", "format": "json"}) + require.Equal(t, "symbol_not_found", extractTextResult(t, result)["condition"], "a missing current file must not serve its old symbol") + require.NotNil(t, srv.graph.GetNode("main.go::helper"), "the read-side tombstone must not mutate base") +} + +func TestProjectedReadRechecksSameStampBytes(t *testing.T) { + srv, dir := setupTestServer(t) + srv.SetWatcher(mutationTestWatcher{}) + path := filepath.Join(dir, "main.go") + original, err := os.ReadFile(path) + require.NoError(t, err) + stamp := time.Now().Add(-time.Hour) + for _, body := range []string{"return 1", "return 2"} { + require.NoError(t, os.WriteFile(path, append(append([]byte(nil), original...), []byte("\nfunc Added() int { "+body+" }\n")...), 0o644)) + require.NoError(t, os.Chtimes(path, stamp, stamp)) + result := callTool(t, srv, "get_symbol_source", map[string]any{"id": "main.go::Added", "context_lines": float64(0), "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + require.Equal(t, "func Added() int { "+body+" }", extractTextResult(t, result)["source"]) + } + require.Nil(t, srv.graph.GetNode("main.go::Added")) +} + +func TestProjectedReadCancellationDoesNotPublish(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(before, []byte("\nfunc Added() {}\n")...), 0o644)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = srv.ensureProjected(ctx, []string{"main.go"}) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, srv.graph.GetNode("main.go::Added")) +} + +func TestProjectedDiskLayerStaysBelowPinnedEditorLayer(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(before, []byte("\nfunc AddedOnDisk() {}\n")...), 0o644)) + layer := graph.NewOverlayLayer() + layer.MarkFile("main.go", false) + bufferNode := &graph.Node{ID: "main.go::InBuffer", Name: "InBuffer", FilePath: "main.go", Kind: graph.KindFunction} + layer.AddNode("main.go", bufferNode) + ctx := WithOverlayView(t.Context(), graph.NewOverlaidView(srv.graph, layer)) + ctx, err = srv.ensureProjected(ctx, []string{"main.go"}) + require.NoError(t, err) + require.Equal(t, bufferNode, srv.readerFor(ctx).GetNode(bufferNode.ID)) + require.Nil(t, srv.readerFor(ctx).GetNode("main.go::AddedOnDisk")) + require.Nil(t, srv.graph.GetNode("main.go::AddedOnDisk")) +} + +func TestPinnedEditorFileNeedsNoDiskProjection(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + // The pinned editor snapshot owns this entire file. Oversized disk bytes + // cannot contribute an answer and must not consume its projection budget. + require.NoError(t, os.WriteFile(path, make([]byte, (1<<20)+1), 0o644)) + layer := graph.NewOverlayLayer() + layer.MarkFile("main.go", false) + bufferNode := &graph.Node{ID: "main.go::InBuffer", Name: "InBuffer", FilePath: "main.go", Kind: graph.KindFunction} + layer.AddNode("main.go", bufferNode) + pinned := graph.NewOverlaidView(srv.graph, layer) + ctx := WithOverlayView(t.Context(), pinned) + ctx, err := srv.ensureProjected(ctx, []string{"main.go"}) + require.NoError(t, err) + require.Same(t, pinned, OverlayViewFromContext(ctx)) + require.Empty(t, requestProjectionLayers(ctx)) + require.Same(t, bufferNode, srv.readerFor(ctx).GetNode(bufferNode.ID)) +} + +func TestProjectedFileFactsDoNotClaimRelationsPublished(t *testing.T) { + srv, dir := setupTestServer(t) + done := make(chan indexer.MutationResult, 1) + t.Cleanup(func() { close(done) }) + srv.SetWatcher(mutationTestWatcher{done: done}) + srv.mutationReindexWait = time.Nanosecond + ack := extractTextResult(t, callTool(t, srv, "edit_file", map[string]any{ + "path": filepath.Join(dir, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc AddedForFacts() {}", "format": "json", + })) + require.Equal(t, true, ack["reindex_pending"]) + parent := srv.captureToolFreshness(t.Context()) + ctx, err := srv.ensureProjected(parent, []string{"main.go"}) + require.NoError(t, err) + require.NotNil(t, srv.readerFor(ctx).GetNode("main.go::AddedForFacts")) + require.Equal(t, "pending", srv.freshnessFor(parent, "relations", nil, []string{"main.go"}).state, "file projection does not publish cross-file relations") + file := srv.freshnessFor(parent, "file", nil, []string{"main.go"}) + require.Empty(t, file.state, "current projected bytes satisfy only the file tier") + fields := make(map[string]any) + file.apply(fields) + require.Equal(t, "projected", fields["file_view"]) + require.Equal(t, true, fields["readback_exact"]) +} + +func TestProjectedReadRendersExactFileFacts(t *testing.T) { + srv, dir := setupTestServer(t) + srv.SetWatcher(mutationTestWatcher{}) + path := filepath.Join(dir, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(before, []byte("\nfunc AddedForFacts() {}\n")...), 0o644)) + result := projectedSourceThroughMiddleware(t, srv, map[string]any{"id": "main.go::AddedForFacts", "format": "json", "context_lines": float64(0)}) + require.False(t, result.IsError) + payload := extractTextResult(t, result) + facts, ok := payload["freshness"].(map[string]any) + require.True(t, ok) + require.Equal(t, "projected", facts["file_view"]) + require.Equal(t, true, facts["readback_exact"]) + require.NotContains(t, facts, "stale", "the old stat-only rider must not contradict a current projected file answer") +} + +func TestProjectedFileFactsUseExistingRendererInEveryMode(t *testing.T) { + for _, format := range []string{"json", "gcx", "toon"} { + for _, compact := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/compact_%t", format, compact), func(t *testing.T) { + srv, dir := setupTestServer(t) + srv.SetWatcher(mutationTestWatcher{}) + path := filepath.Join(dir, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(before, []byte("\nfunc AddedForFacts() {}\n")...), 0o644)) + result := projectedSourceThroughMiddleware(t, srv, map[string]any{"id": "main.go::AddedForFacts", "format": format, "compact": compact, "context_lines": float64(0)}) + require.False(t, result.IsError, "%v", result.Content) + text := resultText(result) + var payload map[string]any + if json.Unmarshal([]byte(text), &payload) == nil && payload != nil { + facts, ok := payload["freshness"].(map[string]any) + require.True(t, ok) + require.Equal(t, "projected", facts["file_view"]) + require.Equal(t, true, facts["readback_exact"]) + } else { + require.Contains(t, text, "file: projected (readback exact)") + require.Equal(t, 1, strings.Count(text, "file: projected (readback exact)")) + } + require.Nil(t, srv.graph.GetNode("main.go::AddedForFacts")) + }) + } + } +} + +func TestUnchangedFileReadAddsNoProjectionFacts(t *testing.T) { + srv, _ := setupTestServer(t) + result := projectedSourceThroughMiddleware(t, srv, map[string]any{"id": "main.go::helper", "format": "json", "context_lines": float64(0)}) + require.False(t, result.IsError) + require.NotContains(t, extractTextResult(t, result), "freshness", "unchanged indexed answers retain the existing bytes") +} + +func projectedSourceThroughMiddleware(t *testing.T, s *Server, arguments map[string]any) *mcp.CallToolResult { + t.Helper() + req := editFileRequest(arguments) + req.Params.Name = "get_symbol_source" + result, err := s.wrapToolHandler(s.handleGetSymbolSource)(t.Context(), req) + require.NoError(t, err) + return result +} + +// A file projection contains local edges, but it does not resolve the changed +// file's cross-file relations. This remains true for an external write with no +// watcher or mutation receipt supplying an independent pending label. +func TestProjectedRelationRichAnswersKeepRelationsPending(t *testing.T) { + for _, tool := range []string{"get_symbol", "get_file_summary", "get_editing_context"} { + t.Run(tool, func(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(before, []byte("\nfunc AddedRelation() { helper() }\n")...), 0o644)) + req := editFileRequest(map[string]any{"id": "main.go::AddedRelation", "path": "main.go", "detail": "full", "format": "json"}) + req.Params.Name = tool + handlers := map[string]func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error){ + "get_symbol": srv.handleGetSymbol, "get_file_summary": srv.handleGetFileSummary, "get_editing_context": srv.handleGetEditingContext, + } + result, err := srv.wrapToolHandler(handlers[tool])(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + facts, ok := extractTextResult(t, result)["freshness"].(map[string]any) + require.True(t, ok, "exact file facts and unresolved relation scope must use the same rider") + require.Equal(t, "relations", facts["freshness_scope"]) + require.Equal(t, "pending", facts["graph_freshness"]) + require.Equal(t, "projected", facts["file_view"]) + require.Equal(t, true, facts["readback_exact"]) + require.Nil(t, srv.graph.GetNode("main.go::AddedRelation"), "read must leave the shared graph unchanged") + }) + } +} + +func TestExternalProjectionSatisfiesOnlyExplicitFileTier(t *testing.T) { + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(before, []byte("\nfunc AddedForTiers() {}\n")...), 0o644)) + parent := withRequestProjectionFacts(t.Context()) + _, err = s.ensureProjected(parent, []string{"main.go"}) + require.NoError(t, err) + for _, scope := range []string{"file", "relations", "derived"} { + facts := s.freshnessFor(parent, scope, nil, []string{"main.go"}) + require.True(t, facts.projected) + if scope == "file" { + require.Empty(t, facts.state, "explicit file scope may describe exact local bytes") + } else { + require.Equal(t, "pending", facts.state, "a file projection cannot certify %s publication", scope) + require.Equal(t, []string{"main.go"}, facts.files) + } + } + require.Nil(t, s.graph.GetNode("main.go::AddedForTiers")) +} diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go new file mode 100644 index 000000000..b1ae2c27b --- /dev/null +++ b/internal/mcp/projected_reads.go @@ -0,0 +1,372 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sync" + "time" + + "github.com/zeebo/blake3" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer/source" + "github.com/zzet/gortex/internal/pathkey" +) + +const projectedReadBudget = 150 * time.Millisecond + +// Each request retains only the immutable file layers it actually checked in +// its selected checkout. Editor buffers compose above these disk projections. +type requestProjectionKey struct{} + +func requestProjectionLayers(ctx context.Context) []graph.OverlayLayerReader { + layers, _ := ctx.Value(requestProjectionKey{}).([]graph.OverlayLayerReader) + return layers +} + +// ensureProjected reads current tracked bytes without publishing graph/store +// state. Failure is explicit: an old graph must never masquerade as a current +// answer after the checked file could not be projected. +func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.Context, error) { + if requestReadsCommittedTree(ctx) || !requestViewFromContext(ctx).acceptsBufferOverlay() { + return ctx, nil + } + ctx = withRequestProjectionFacts(ctx) + if err := validateRequestProjections(ctx); err != nil { + return ctx, err + } + buildCtx, cancel := context.WithTimeout(ctx, projectedReadBudget) + defer cancel() + layers := append([]graph.OverlayLayerReader(nil), requestProjectionLayers(ctx)...) + for _, graphPath := range paths { + if err := buildCtx.Err(); err != nil { + return ctx, err + } + if editor := OverlayViewFromContext(ctx); editor != nil && editor.LayerReader() != nil && editor.LayerReader().HasFile(graphPath) { + // This pinned buffer completely owns the file. Disk contributes + // neither nodes nor edges, so do not read or parse it underneath. + continue + } + borrowed := false + for _, layer := range layers { + if layer.HasFile(graphPath) { + borrowed = true + break + } + } + if borrowed { + if !s.hasRequestProjectionFact(ctx, graphPath) { + return ctx, projectionSuperseded + } + // Candidate enumeration and hydration must keep one immutable + // selection. Supersession was refused above; never append a newer + // same-file layer beneath already selected older candidates. + continue + } + idx, rel := s.indexerForRel(graphPath) + if idx == nil || !idx.IsTracked(rel) { + continue // untracked-file tools retain their raw-source guidance + } + abs, err := s.resolveGraphPath(ctx, graphPath) + if err != nil { + return ctx, err + } + entry, err := s.projectCurrentTrackedFile(buildCtx, ctx, abs, graphPath, rel) + if err != nil { + return ctx, fmt.Errorf("file graph pending for %s: %w", graphPath, err) + } + if entry != nil { + if s.retireSelectedProjection(buildCtx, ctx, entry) { + continue + } + if err := buildCtx.Err(); err != nil { + return ctx, err + } + layer, err := projectedFileVisibility(buildCtx, s.requestSelectedBaseReader(ctx), entry) + if err != nil { + // Carry is optional relation evidence. Unknown checked capability + // keeps the exact file projection, with its pending relation facts. + // Required disk/parse failures above and actual cancellation still + // refuse; no errorless or canonical carry fallback is introduced. + layer = entry.layer + } + if err := buildCtx.Err(); err != nil { + return ctx, err + } + ctx, err = retainRequestProjection(ctx, entry) + if err != nil { + return ctx, err + } + layers = append(layers, layer) + } + } + if err := validateRequestProjections(ctx); err != nil { + return ctx, err + } + if len(layers) == 0 { + return ctx, nil + } + ctx = context.WithValue(ctx, requestProjectionKey{}, layers) + if editor := OverlayViewFromContext(ctx); editor != nil { + // Reuse the request's pinned buffer layer; do not parse it again or + // change its session/snapshot authority. + ctx = WithOverlayView(ctx, graph.NewOverlaidViewWithLayer(s.requestBaseReader(ctx), editor.LayerReader())) + } + return ctx, nil +} + +func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, abs, graphPath, rel string) (*projectionEntry, error) { + idx, _ := s.indexerForRel(graphPath) + key := pathkey.MutationKey(abs) + expected := pendingFileProjections.snap.Load().entries[key] + before, err := statDiskStamp(abs) + if err != nil { + return nil, err + } + if before.info == nil { + entry := newProjectionTombstone(abs, graphPath, idx.RepoPrefix(), 0) + latest, err := statDiskStamp(abs) + if err != nil || latest.info != nil || pathkey.MutationKey(abs) != key { + return nil, errors.Join(err, errProjectionExternalWrite) + } + if err := pendingFileProjections.publishExpected(entry, expected); err != nil { + return nil, err + } + return entry, nil + } + // Reuse the confined, bounded, nonblocking regular-file reader. A FIFO, + // symlink or unsupported platform is a refusal, never an unsafe Open. + files, err := source.NewFilesystemSource(filepath.Dir(abs)) + if err != nil { + return nil, err + } + defer files.Close() + raw, _, err := files.ReadRegularFile(buildCtx, filepath.Base(abs), 1<<20) + if err != nil { + return nil, err + } + after, err := statDiskStamp(abs) + if err != nil || !before.matches(after) { + return nil, errors.Join(err, errProjectionExternalWrite) + } + if !requestViewFromContext(requestCtx).routed() && idx.IndexedContentMatches(rel, raw) { + return nil, nil + } + // This path already read the bounded, confined current bytes. Never call + // legacy lookup/onDisk here: its suspect rehash performs another raw read. + rawHash := blake3.Sum256(raw) + if expected != nil { + if !expected.tombstone && expected.rawHash == rawHash && expected.disk.matches(after) && + pathkey.MutationKey(abs) == key && pendingFileProjections.snap.Load().entries[key] == expected { + return expected, nil + } + // Invalidate the old bytes on every exit, including cache refusal. + // Do it after admission so this read's own old-version invalidation + // cannot be inherited as an event against the newly verified bytes. + // Actual concurrent watcher events still set the old flag before + // publishExpected and are conservatively inherited by that publish. + defer expected.suspect.Store(true) + } + // Use the registered parser identity while retaining the physical + // checkout path as the registry key and disk identity. + projection, err := idx.ProjectSource(buildCtx, s.overlayOwnerAbsPath(requestCtx, abs), raw) + if err != nil { + return nil, err + } + entry := newProjectionEntry(abs, idx.RepoPrefix(), projection, raw, 0, after) + latest, err := statDiskStamp(abs) + if err != nil || !after.matches(latest) { + return nil, errors.Join(err, errProjectionExternalWrite) + } + // Stat identity alone misses equal-size in-place edits with restored mtime, + // including a newer version that retired while this extraction was running. + current, _, readErr := files.ReadRegularFile(buildCtx, filepath.Base(abs), 1<<20) + finalStamp, statErr := statDiskStamp(abs) + if readErr != nil || statErr != nil || !latest.matches(finalStamp) || blake3.Sum256(current) != rawHash || pathkey.MutationKey(abs) != key { + return nil, errors.Join(readErr, statErr, errProjectionExternalWrite) + } + if err := pendingFileProjections.publishExpected(entry, expected); err != nil { + return nil, err + } + return entry, nil +} + +// Projection facts are request-owned observations consumed by the existing +// tier producer. They are shared with the outer response decorator, while the +// graph layers retained by each derived context remain immutable snapshots. +type requestProjectionFactsKey struct{} +type requestProjectionFacts struct { + mu sync.RWMutex + entries map[string]*projectionEntry +} + +func withRequestProjectionFacts(ctx context.Context) context.Context { + if _, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts); ok { + return ctx + } + return context.WithValue(ctx, requestProjectionFactsKey{}, &requestProjectionFacts{entries: make(map[string]*projectionEntry)}) +} + +// retainRequestProjection shares the existing request-owned freshness facts +// with candidate and row selection. It adds no registry or publication owner. +func retainRequestProjection(ctx context.Context, entry *projectionEntry) (context.Context, error) { + ctx = withRequestProjectionFacts(ctx) + if entry == nil || !liveProjectionForAcknowledgement(entry) { + return ctx, projectionSuperseded + } + facts := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + facts.mu.Lock() + defer facts.mu.Unlock() + if prior := facts.entries[entry.key]; prior != nil && prior != entry { + return ctx, projectionSuperseded + } + facts.entries[entry.key] = entry + return ctx, nil +} + +// Re-entry must reject the entire old selection, including unchanged bytes +// owned by a newer physical execution. Only borrowed entries are examined; +// there is no global rebuild, candidate scan, raw read or parser invocation. +func validateRequestProjections(ctx context.Context) error { + facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + if !ok { + return nil + } + facts.mu.RLock() + defer facts.mu.RUnlock() + if len(facts.entries) == 0 { + return nil + } + if err := ctx.Err(); err != nil { + return err + } + for _, entry := range facts.entries { + if !liveProjectionForAcknowledgement(entry) { + return projectionSuperseded + } + } + return nil +} + +func withProjectedReadToolFacts(ctx context.Context, name string) context.Context { + switch name { + case "get_symbol", "get_symbol_source", "get_file_summary", "get_editing_context", "get_cfg": + return withRequestProjectionFacts(ctx) + default: + return ctx + } +} + +func (s *Server) hasRequestProjectionFact(ctx context.Context, path string) bool { + facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + if !ok { + return false + } + facts.mu.RLock() + empty := len(facts.entries) == 0 + facts.mu.RUnlock() + if empty { + return false + } // idle producer does no path resolution or stat + abs, err := s.resolveGraphPath(ctx, path) + if err != nil { + return false + } + key := pathkey.MutationKey(abs) + facts.mu.RLock() + entry := facts.entries[key] + facts.mu.RUnlock() + return liveProjectionForAcknowledgement(entry) +} + +// Carry is a request-local visibility fact, never a mutation of a published +// projection or credit for background relations. This base excludes both the +// request's disk projections and editor buffers. +func (s *Server) requestSelectedBaseReader(ctx context.Context) graph.Reader { + if view := requestViewFromContext(ctx); view.routed() { + return view.reader + } + return s.graph +} + +// The existing matching algorithm reads only this bounded, checked snapshot of +// the same selected base. No contextless store lookup escapes the read budget. +type projectedCarryBase struct { + graph.Reader + nodes []*graph.Node + outgoing map[string][]*graph.Edge + targets map[string]*graph.Node +} + +func (p *projectedCarryBase) GetFileNodes(string) []*graph.Node { return p.nodes } +func (p *projectedCarryBase) GetOutEdgesByNodeIDs([]string) map[string][]*graph.Edge { + return p.outgoing +} +func (p *projectedCarryBase) GetNodesByIDs([]string) map[string]*graph.Node { return p.targets } + +func projectedFileVisibility(ctx context.Context, base graph.Reader, entry *projectionEntry) (graph.OverlayLayerReader, error) { + var unresolved []*graph.Edge + for edge := range entry.layer.Edges() { + if edge != nil && edge.Kind == graph.EdgeCalls && graph.IsUnresolvedTarget(edge.To) && !graph.IsFnValuePlaceholder(edge.To) && edge.FilePath == entry.graphPath && graph.IDFile(edge.From) == entry.graphPath { + unresolved = append(unresolved, edge) + } + } + if entry.tombstone || len(unresolved) == 0 { + return entry.layer, nil + } + files, err := graph.ContractFileNodesContext(ctx, base, entry.repoPrefix, []string{entry.graphPath}) + if err != nil { + return nil, err + } + nodes := files[entry.graphPath] + if len(nodes) == 0 { + return entry.layer, ctx.Err() + } + ids := make([]string, 0, len(nodes)) + for _, node := range nodes { + ids = append(ids, node.ID) + } + outgoing, truncated, err := graph.GetOutEdgesByNodeIDsWithMetadataContext(ctx, base, ids, graph.ContractProjectionRowLimit) + if err != nil { + return nil, err + } + if truncated { + return nil, graph.ErrContractProjectionLimit + } + var targetIDs []string + for _, edges := range outgoing { + for _, edge := range edges { + if edge != nil && edge.Kind == graph.EdgeCalls && edge.FilePath == entry.graphPath && !graph.IsUnresolvedTarget(edge.To) { + targetIDs = append(targetIDs, edge.To) + } + } + } + if len(targetIDs) == 0 { + return entry.layer, ctx.Err() + } + targets, err := graph.ContractSourceNodesContext(ctx, base, targetIDs) + if err != nil { + return nil, err + } + carried := graph.CarryBaseEdges(&projectedCarryBase{Reader: base, nodes: nodes, outgoing: outgoing, targets: targets}, entry.graphPath, unresolved) + if err := ctx.Err(); err != nil { + return nil, err + } + if len(carried) == 0 { + return entry.layer, nil + } + layer := graph.NewOverlayLayer() + layer.MarkFile(entry.graphPath, false) + for _, node := range entry.layer.FileNodes(entry.graphPath) { + layer.AddNode(entry.graphPath, node) + } + for edge := range entry.layer.Edges() { + layer.AddEdge(edge) + } + for _, edge := range carried { + layer.AddEdge(edge) + } + return layer, nil +} diff --git a/internal/mcp/read_path_guard_test.go b/internal/mcp/read_path_guard_test.go index ce722910d..3795aa56b 100644 --- a/internal/mcp/read_path_guard_test.go +++ b/internal/mcp/read_path_guard_test.go @@ -26,7 +26,7 @@ var reindexSinks = map[string]bool{ // readPathGuardEnforced turns the guard from a report into a gate once read // handlers read through pending edits instead of reindexing. -const readPathGuardEnforced = false +const readPathGuardEnforced = true // packageRefs maps every function declared in files, by bare name, to the // names its body references, called or passed as a value, and every tool diff --git a/internal/mcp/read_repair_observation.go b/internal/mcp/read_repair_observation.go index f196bd12a..77609a766 100644 --- a/internal/mcp/read_repair_observation.go +++ b/internal/mcp/read_repair_observation.go @@ -269,27 +269,3 @@ func (w mutationReadRepairWitness) bind(accepted mutationReadRepairObservation) accepted.acceptedBlobSHA = w.blobSHA record.readRepair = &accepted } - -// Only the already-needed multi-repository read repair is observed. Embedded -// and unobserved callers preserve their existing fallback and nil behavior. -func (s *Server) reindexFileForRead(ctx context.Context, absPath string, idx *indexer.Indexer) bool { - if s.multiIndexer != nil { - if prefix := s.multiIndexer.RepoForFile(absPath); prefix != "" { - result, err := s.multiIndexer.IncrementalReindexRepoObserved(prefix, []string{absPath}, s.readRepairObserver(ctx, absPath, idx)) - if incrementalReindexSucceeded(result, err) { - return true - } - } - } - // Match reindexFile's existing standalone fallback without submitting the - // multi-repository reconcile twice after a failure. - if s.indexer != nil { - if root := s.indexer.RootPath(); root != "" { - if _, ok := relativeWithinRoot(root, absPath); ok { - result, err := s.indexer.IncrementalReindexPaths(root, []string{absPath}) - return incrementalReindexSucceeded(result, err) - } - } - } - return false -} diff --git a/internal/mcp/relation_freshness.go b/internal/mcp/relation_freshness.go index e97072bc0..a8bf2e695 100644 --- a/internal/mcp/relation_freshness.go +++ b/internal/mcp/relation_freshness.go @@ -27,10 +27,11 @@ 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 { - 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 + projected bool // current request served an exact file-local projection + 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 } // relationWaitDeadline is when a change tool stops waiting for its relation @@ -248,7 +249,7 @@ func (s *Server) labelRelations(ctx context.Context, gaps []*mutationReceipt, id // being re-read as its JSON object first. A fresh scope returns payload // unchanged. func (f relationFreshness) labelled(payload any) any { - if f.state == "" { + if f.state == "" && !f.projected { return payload } data, err := json.Marshal(payload) diff --git a/internal/mcp/selected_projection_retirement.go b/internal/mcp/selected_projection_retirement.go new file mode 100644 index 000000000..67d3b6fd3 --- /dev/null +++ b/internal/mcp/selected_projection_retirement.go @@ -0,0 +1,202 @@ +package mcp + +import ( + "context" + "maps" + "reflect" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/graphview" + "github.com/zzet/gortex/internal/pathkey" +) + +// Retirement is optional proof about the same selected reader, never a reason +// to consult canonical inventory. Keep its lease until the exact-pointer COW +// drop finishes; no I/O or layer check runs under the registry mutex. +func (s *Server) retireSelectedProjection(ctx, requestCtx context.Context, entry *projectionEntry) bool { + view := requestViewFromContext(requestCtx) + if view == nil || view.materialized == nil || view.reader == nil || !requestViewPathRoot(requestCtx).contains(entry.absPath) || viewRepoPrefix(view) != entry.repoPrefix { + return false + } + abs, err := s.resolveGraphPath(requestCtx, entry.graphPath) + if err != nil || pathkey.MutationKey(abs) != entry.key { + return false + } + handoff := view.materialized.Handoff() + if handoff == nil { + return false + } + defer handoff.Close() + if !reflect.ValueOf(view.reader).Comparable() || view.reader != handoff.Reader { + return false + } + checked, ok := handoff.Reader.(graph.ConstantValueProjectionReader) + if !ok { + return false + } + adapter := &selectedProjectionStore{ctx: ctx, reader: handoff.Reader, checked: checked, sources: handoff.GenerationSources(), entry: entry} + return pendingFileProjections.retireSelected(adapter, map[string]struct{}{entry.key: {}}) == 1 +} + +// This private adapter supplies the legacy retirement algorithm only checked +// selected metadata/full-node evidence. Its sticky error closes the errorless +// node-read slot; a failed or partial read cannot later certify an empty file. +type selectedProjectionStore struct { + ctx context.Context + reader graph.Reader + checked graph.ConstantValueProjectionReader + sources []graphview.GenerationSource + entry *projectionEntry + revisions []store_sqlite.PayloadInputRevision + initial map[string]graph.FileMetaRow + err error +} + +func (p *selectedProjectionStore) validateLayers() error { + if p.err != nil { + return p.err + } + if err := p.ctx.Err(); err != nil { + return err + } + first := p.revisions == nil + if first { + p.revisions = make([]store_sqlite.PayloadInputRevision, len(p.sources)) + } + for i, source := range p.sources { + if source.Handle == nil || source.Layer == nil { + return graph.ErrConstantProjectionUnsupported + } + before, err := source.Handle.PayloadInputRevisionContext(p.ctx) + if err != nil { + return err + } + checked, ok := source.Layer.(graph.OverlayLayerConstantValueReader) + if !ok { + return graph.ErrConstantProjectionUnsupported + } + if _, err := checked.LayerConstantValueProjectionContext(p.ctx, nil, nil); err != nil { + return err + } + after, err := source.Handle.PayloadInputRevisionContext(p.ctx) + if err != nil { + return err + } + if before != after || !first && p.revisions[i] != after { + return graph.ErrConstantProjectionStale + } + if first { + p.revisions[i] = after + } + } + return p.ctx.Err() +} + +func (p *selectedProjectionStore) selectedDeletion() (bool, error) { + // Newest ownership wins. Even skipped higher sources have already passed + // their checked layer/revision validation and are checked again afterward. + for i := len(p.sources) - 1; i >= 0; i-- { + source := p.sources[i] + if !source.Layer.HasFile(p.entry.graphPath) { + continue + } + if !source.Layer.IsTombstone(p.entry.graphPath) { + return false, nil + } + before, err := source.Handle.PayloadInputRevisionContext(p.ctx) + if err != nil { + return false, err + } + rows, err := source.Handle.FileMasksForContext(p.ctx, p.entry.repoPrefix, []string{p.entry.graphPath}) + if err != nil { + return false, err + } + after, err := source.Handle.PayloadInputRevisionContext(p.ctx) + if err != nil { + return false, err + } + if before != after || after != p.revisions[i] { + return false, graph.ErrConstantProjectionStale + } + return len(rows) == 1 && rows[0].RepoPrefix == p.entry.repoPrefix && rows[0].FilePath == p.entry.graphPath && rows[0].Mode == store_sqlite.OwnershipDelete, nil + } + return false, nil +} + +func (p *selectedProjectionStore) FileMetasByPaths(prefix string, paths []string) (rows map[string]graph.FileMetaRow, err error) { + defer func() { + if err != nil { + p.err = err + } + }() + if p.err != nil { + return nil, p.err + } + if prefix != p.entry.repoPrefix || len(paths) != 1 || paths[0] != p.entry.graphPath { + return nil, graph.ErrConstantProjectionUnsupported + } + if err = p.validateLayers(); err != nil { + return nil, err + } + key := graph.ConstantFileKey{RepoPrefix: prefix, FilePath: paths[0]} + proof, err := p.checked.ReadConstantValueProjectionContext(p.ctx, nil, []graph.ConstantFileKey{key}) + if err != nil { + return nil, err + } + rows = make(map[string]graph.FileMetaRow) + if row, ok := proof.Files[key]; ok { + if row.FilePath != paths[0] || row.ContentHash == "" { + return nil, graph.ErrConstantProjectionIncomplete + } + rows[paths[0]] = row + } else if p.entry.tombstone { + deleted, deleteErr := p.selectedDeletion() + if deleteErr != nil { + return nil, deleteErr + } + if !deleted { + return nil, graph.ErrConstantProjectionIncomplete + } + } + if err = p.validateLayers(); err != nil { + return nil, err + } + if p.initial == nil { + p.initial = maps.Clone(rows) + } + return rows, nil +} + +func (p *selectedProjectionStore) GetFileNodes(path string) []*graph.Node { + if path != p.entry.graphPath { + p.err = graph.ErrContractProjectionUnsupported + return nil + } + if p.err = p.validateLayers(); p.err != nil { + return nil + } + rows, err := graph.ContractFileNodesContext(p.ctx, p.reader, p.entry.repoPrefix, []string{path}) + if err != nil { + p.err = err + return nil + } + if p.err = p.validateLayers(); p.err != nil { + return nil + } + return rows[path] +} + +// Called after hash/node/hash, before acquiring the existing registry COW +// mutex. Rechecking this selected stack is read proof, not new publication +// authority: the accepted content hash/durable delete mask remains required. +func (p *selectedProjectionStore) validateProjectionRetirement() error { + rows, err := p.FileMetasByPaths(p.entry.repoPrefix, []string{p.entry.graphPath}) + if err != nil { + return err + } + if !maps.Equal(p.initial, rows) { + return graph.ErrConstantProjectionStale + } + return p.validateLayers() +} diff --git a/internal/mcp/selected_projection_retirement_test.go b/internal/mcp/selected_projection_retirement_test.go new file mode 100644 index 000000000..712684acd --- /dev/null +++ b/internal/mcp/selected_projection_retirement_test.go @@ -0,0 +1,459 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/pathkey" +) + +func selectedProjectionRegistry(t *testing.T) { + t.Helper() + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) +} + +func selectedPendingEntry(t *testing.T, stack *viewStack) *projectionEntry { + t.Helper() + ctx := routedEnrichmentCtx(t, stack) + path := filepath.Join(stack.worktreeRoot, "keep.go") + require.NoError(t, os.WriteFile(path, []byte("package repo\n\nfunc Keeper() {}\nfunc Latest() {}\n"), 0o644)) + entry, err := stack.srv.projectCurrentTrackedFile(t.Context(), ctx, path, "repo/keep.go", "keep.go") + require.NoError(t, err) + require.NotNil(t, entry) + return entry +} + +func publishSelectedEntry(t *testing.T, stack *viewStack, entry *projectionEntry, deleted bool) { + t.Helper() + generation, handle, err := stack.store.BeginPayloadGeneration(t.Context(), store_sqlite.PayloadGenerationRequest{ + OwnerKind: "dedicated_graph", GraphID: stack.graphID, LayerID: viewTestDirtyLayerID, CheckoutID: viewTestWorktree, GenerationKind: "dirty", BaseGenerationID: stack.dirty, TreeOID: "selected-current", CreatedAt: 5000, + }) + require.NoError(t, err) + mode := store_sqlite.OwnershipReplace + if deleted { + mode = store_sqlite.OwnershipDelete + } else { + handle.AddBatch(entry.layer.FileNodes(entry.graphPath), nil) + require.NoError(t, handle.SetFileMetas(entry.repoPrefix, []graph.FileMetaRow{{FilePath: entry.graphPath, ContentHash: entry.hash}})) + } + require.NoError(t, handle.SetFileMasks([]store_sqlite.FileMask{{RepoPrefix: entry.repoPrefix, FilePath: entry.graphPath, Mode: mode}})) + require.NoError(t, stack.store.PublishPayloadGeneration(t.Context(), generation, 6000)) + routeViewCheckout(t, stack.store, stack.graphID, stack.commit, generation, store_sqlite.RouteActive) + stack.dirty = generation +} + +func TestSelectedProjectionRetiresCaughtUpLeasedReader(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + entry := selectedPendingEntry(t, stack) + publishSelectedEntry(t, stack, entry, false) + ctx := routedEnrichmentCtx(t, stack) + _, err := stack.srv.ensureProjected(ctx, []string{entry.graphPath}) + require.NoError(t, err) + require.Nil(t, liveEntry(pendingFileProjections, entry.absPath), "selected accepted bytes must retire without consulting canonical old inventory") +} + +func TestSelectedProjectionRetirementKeepsSiblingPhysicalEntry(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + entry := selectedPendingEntry(t, stack) + siblingPath := filepath.Join(stack.repoRoot, "keep.go") + sibling := entry.committedCopy(0, entry.disk) + sibling.absPath = siblingPath + sibling.key = pathkey.MutationKey(siblingPath) + require.NoError(t, pendingFileProjections.publish(sibling)) + publishSelectedEntry(t, stack, entry, false) + _, err := stack.srv.ensureProjected(routedEnrichmentCtx(t, stack), []string{entry.graphPath}) + require.NoError(t, err) + require.Nil(t, liveEntry(pendingFileProjections, entry.absPath)) + require.Same(t, sibling, liveEntry(pendingFileProjections, sibling.absPath)) +} + +func TestSelectedProjectionDurableDeleteRetiresTombstone(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + entry := selectedPendingEntry(t, stack) + require.NoError(t, os.Remove(entry.absPath)) + tombstone, err := stack.srv.projectCurrentTrackedFile(t.Context(), routedEnrichmentCtx(t, stack), entry.absPath, entry.graphPath, "keep.go") + require.NoError(t, err) + require.True(t, tombstone.tombstone) + publishSelectedEntry(t, stack, tombstone, true) + _, err = stack.srv.ensureProjected(routedEnrichmentCtx(t, stack), []string{entry.graphPath}) + require.NoError(t, err) + require.Nil(t, liveEntry(pendingFileProjections, entry.absPath), "durable newest selected ownership-delete is positive deletion proof") +} + +func projectedCarryFixture(t *testing.T, selected bool) (*Server, context.Context, string) { + t.Helper() + selectedProjectionRegistry(t) + srv, root := newRelationFixture(t) + var reader graph.Reader = srv.graph + if selected { + layer := graph.NewOverlayLayer() + layer.MarkFile("target.go", false) + layer.AddNode("target.go", &graph.Node{ID: "target.go::SelectedTarget", Name: "Target", FilePath: "target.go", Kind: graph.KindFunction}) + layer.AddEdge(&graph.Edge{From: "caller.go::Caller", To: "target.go::SelectedTarget", Kind: graph.EdgeCalls, FilePath: "caller.go", Line: 4}) + reader = graph.NewOverlaidView(srv.graph, layer) + } + ctx := t.Context() + if selected { + ctx = withRequestView(ctx, &requestView{reader: reader, viewRoot: root, kind: requestViewKindWorktree}) + } + require.NoError(t, os.WriteFile(filepath.Join(root, "caller.go"), []byte("package p\n\n// Current caller documentation.\nfunc Caller() {\n Target()\n}\n"), 0o644)) + return srv, ctx, "caller.go::Caller" +} + +func TestProjectedCarryPreservesCurrentCallerAndCallee(t *testing.T) { + srv, ctx, caller := projectedCarryFixture(t, false) + ctx, err := srv.ensureProjected(ctx, []string{"caller.go"}) + require.NoError(t, err) + var carried *graph.Edge + for _, edge := range srv.readerFor(ctx).GetOutEdges(caller) { + if edge.To == "target.go::Target" { + carried = edge + } + } + require.NotNil(t, carried, "current unresolved call must preserve matching base adjacency") + require.True(t, carried.Provisional) + require.Equal(t, 5, carried.Line) + require.Contains(t, srv.readerFor(ctx).GetInEdges("target.go::Target"), carried) +} + +func TestProjectedCarryUsesSameSelectedBase(t *testing.T) { + srv, ctx, caller := projectedCarryFixture(t, true) + ctx, err := srv.ensureProjected(ctx, []string{"caller.go"}) + require.NoError(t, err) + var found bool + for _, edge := range srv.readerFor(ctx).GetOutEdges(caller) { + require.NotEqual(t, "target.go::Target", edge.To, "canonical target must not leak into selected carry") + found = found || edge.To == "target.go::SelectedTarget" && edge.Provisional + } + require.True(t, found) +} + +func TestProjectedCarryKeepsImmutableFullNodes(t *testing.T) { + srv, ctx, caller := projectedCarryFixture(t, false) + ctx, err := srv.ensureProjected(ctx, []string{"caller.go"}) + require.NoError(t, err) + entry := liveEntry(pendingFileProjections, filepath.Join(srv.indexer.RootPath(), "caller.go")) + require.NotNil(t, entry) + require.Same(t, entry.layer.NodeByID(caller), srv.readerFor(ctx).GetNode(caller), "full extracted nodes including Meta must remain intact") + for _, edges := range entry.layer.OutEdgesByFromAll() { + for _, edge := range edges { + require.False(t, edge.Provisional, "published registry layer must stay immutable") + } + } + var found bool + for _, edge := range srv.readerFor(ctx).GetOutEdges(caller) { + found = found || edge.Provisional + } + require.True(t, found, "request visibility carries copies, not registry mutations") +} + +type selectedRetirementProbe struct { + graph.Reader + metadataCalls int + beforeMetadata func(int) + beforeNodes func() + metadataError error + nodesError error + waitMetadataContext bool + waitNodesContext bool +} + +func (p *selectedRetirementProbe) ReadConstantValueProjectionContext(ctx context.Context, ids []string, files []graph.ConstantFileKey) (graph.ConstantValueProjection, error) { + p.metadataCalls++ + if p.beforeMetadata != nil { + p.beforeMetadata(p.metadataCalls) + } + if p.waitMetadataContext { + <-ctx.Done() + return graph.ConstantValueProjection{}, ctx.Err() + } + if p.metadataError != nil { + return graph.ConstantValueProjection{}, p.metadataError + } + return p.Reader.(graph.ConstantValueProjectionReader).ReadConstantValueProjectionContext(ctx, ids, files) +} + +func (p *selectedRetirementProbe) LayerContractFileNodesContext(ctx context.Context, repo string, paths []string) (map[string][]*graph.Node, error) { + if p.beforeNodes != nil { + p.beforeNodes() + } + if p.waitNodesContext { + <-ctx.Done() + return nil, ctx.Err() + } + if p.nodesError != nil { + return nil, p.nodesError + } + return graph.ContractFileNodesContext(ctx, p.Reader, repo, paths) +} + +func TestSelectedProjectionUnknownProofRetainsWithoutCanonicalFallback(t *testing.T) { + for _, mode := range []string{"old selected", "unsupported", "metadata error", "node error", "closed lease"} { + t.Run(mode, func(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + entry := selectedPendingEntry(t, stack) + // Even matching canonical metadata cannot authorize a selected checkout. + require.NoError(t, stack.store.SetFileMetas(entry.repoPrefix, []graph.FileMetaRow{{FilePath: entry.graphPath, ContentHash: entry.hash}})) + if mode != "old selected" { + publishSelectedEntry(t, stack, entry, false) + } + ctx, view := routedEnrichmentView(t, stack) + switch mode { + case "unsupported": + wrapper := struct{ graph.Reader }{view.reader} + view.reader = wrapper + view.materialized.Reader = wrapper + case "metadata error", "node error": + probe := &selectedRetirementProbe{Reader: view.reader} + if mode == "metadata error" { + probe.metadataError = graph.ErrConstantProjectionIncomplete + } else { + probe.nodesError = graph.ErrContractProjectionLimit + } + view.reader = probe + view.materialized.Reader = probe + case "closed lease": + view.close() + } + _, err := stack.srv.ensureProjected(ctx, []string{entry.graphPath}) + require.NoError(t, err) + require.Same(t, entry, liveEntry(pendingFileProjections, entry.absPath), "unknown selected proof must retain the frontier") + }) + } +} + +func TestSelectedProjectionNewerPointerDuringNodesSurvives(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + entry := selectedPendingEntry(t, stack) + publishSelectedEntry(t, stack, entry, false) + ctx, view := routedEnrichmentView(t, stack) + newer := entry.committedCopy(9, entry.disk) + probe := &selectedRetirementProbe{Reader: view.reader, beforeNodes: func() { require.NoError(t, pendingFileProjections.publish(newer)) }} + view.reader = probe + view.materialized.Reader = probe + _, err := stack.srv.ensureProjected(ctx, []string{entry.graphPath}) + require.NoError(t, err) + require.Same(t, newer, liveEntry(pendingFileProjections, entry.absPath), "checked selected nodes cannot remove a later registry pointer") +} + +func TestSelectedProjectionHandoffKeepsLeaseThroughProof(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + entry := selectedPendingEntry(t, stack) + publishSelectedEntry(t, stack, entry, false) + ctx, view := routedEnrichmentView(t, stack) + generation := view.materialized.Generations()[len(view.materialized.Generations())-1] + observed := false + probe := &selectedRetirementProbe{Reader: view.reader, beforeMetadata: func(_ int) { + view.close() + require.True(t, stack.leases.InUse(generation), "synchronous proof must keep the same reader leased after origin close") + observed = true + }} + view.reader = probe + view.materialized.Reader = probe + _, err := stack.srv.ensureProjected(ctx, []string{entry.graphPath}) + require.NoError(t, err) + require.True(t, observed) + require.Nil(t, liveEntry(pendingFileProjections, entry.absPath)) + require.False(t, stack.leases.InUse(generation), "proof handoff must be joined before return") +} + +func TestSelectedProjectionDeleteNeedsPositiveNewestOwnership(t *testing.T) { + for _, mode := range []string{"old replacement", "flat absence", "higher replacement"} { + t.Run(mode, func(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + entry := selectedPendingEntry(t, stack) + require.NoError(t, os.Remove(entry.absPath)) + tombstone, err := stack.srv.projectCurrentTrackedFile(t.Context(), routedEnrichmentCtx(t, stack), entry.absPath, entry.graphPath, "keep.go") + require.NoError(t, err) + if mode == "higher replacement" { + publishSelectedEntry(t, stack, tombstone, true) + publishSelectedEntry(t, stack, entry, false) + } + ctx, view := routedEnrichmentView(t, stack) + if mode == "flat absence" { + empty := graph.New() + view.reader = empty + view.materialized.Reader = empty + } + _, err = stack.srv.ensureProjected(ctx, []string{entry.graphPath}) + require.NoError(t, err) + retained := liveEntry(pendingFileProjections, entry.absPath) + require.NotNil(t, retained, "absence or a higher replacement cannot authorize tombstone retirement") + require.True(t, retained.tombstone) + }) + } +} + +func TestSelectedProjectionChangedAncestorRetainsAfterFirstHash(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + entry := selectedPendingEntry(t, stack) + publishSelectedEntry(t, stack, entry, false) + ctx, view := routedEnrichmentView(t, stack) + changed := false + probe := &selectedRetirementProbe{Reader: view.reader, beforeNodes: func() { + correction, err := stack.store.BeginDerivedCorrection(t.Context(), store_sqlite.DerivedCorrectionRequest{GenerationID: stack.commit, Pass: "capability", FromVersion: 0, ToVersion: 1, EdgeKinds: []graph.EdgeKind{graph.EdgeAccessesField}}) + require.NoError(t, err) + node := stack.store.AtGeneration(stack.commit).GetNode("repo/edit.go::New") + require.NotNil(t, node) + updated := *node + updated.Meta = map[string]any{"source_derived_decl_fingerprint": "changed-during-selected-proof"} + require.NoError(t, correction.ReplaceSourceEdges(t.Context(), []string{node.ID}, nil, []*graph.Node{&updated})) + _, err = correction.Finish(t.Context()) + require.NoError(t, err) + changed = true + }} + view.reader = probe + view.materialized.Reader = probe + _, err := stack.srv.ensureProjected(ctx, []string{entry.graphPath}) + require.NoError(t, err) + require.True(t, changed, "actual lower-layer revision must move during proof") + require.Same(t, entry, liveEntry(pendingFileProjections, entry.absPath)) +} + +func TestSelectedProjectionCheckedReadsStayOutsideRegistryMutex(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + entry := selectedPendingEntry(t, stack) + publishSelectedEntry(t, stack, entry, false) + ctx, view := routedEnrichmentView(t, stack) + observed := false + check := func() { + require.True(t, pendingFileProjections.mu.TryLock(), "checked selected I/O must never hold registry mutex") + pendingFileProjections.mu.Unlock() + observed = true + } + probe := &selectedRetirementProbe{Reader: view.reader, beforeNodes: check, beforeMetadata: func(int) { check() }} + view.reader = probe + view.materialized.Reader = probe + _, err := stack.srv.ensureProjected(ctx, []string{entry.graphPath}) + require.NoError(t, err) + require.True(t, observed) + require.Nil(t, liveEntry(pendingFileProjections, entry.absPath)) +} + +func TestProjectedCarryDoesNotResurrectRemovedOrDifferentSourceCalls(t *testing.T) { + for _, current := range []string{"package p\nfunc Caller() { Other() }\n", "package p\nfunc Different() { Target() }\n"} { + t.Run(current, func(t *testing.T) { + srv, ctx, _ := projectedCarryFixture(t, false) + require.NoError(t, os.WriteFile(filepath.Join(srv.indexer.RootPath(), "caller.go"), []byte(current), 0o644)) + ctx, err := srv.ensureProjected(ctx, []string{"caller.go"}) + require.NoError(t, err) + for _, node := range srv.readerFor(ctx).GetFileNodes("caller.go") { + for _, edge := range srv.readerFor(ctx).GetOutEdges(node.ID) { + require.False(t, edge.Provisional, "a removed call or another source cannot inherit the old caller's relation") + } + } + }) + } +} + +func TestProjectedCarryUnsupportedKeepsExactPendingFile(t *testing.T) { + srv, ctx, caller := projectedCarryFixture(t, false) + // The selected read surface predates the optional checked carry APIs. + reader := struct{ graph.Reader }{srv.graph} + ctx = withRequestView(ctx, &requestView{reader: reader, viewRoot: srv.indexer.RootPath(), kind: requestViewKindWorktree}) + ctx, err := srv.ensureProjected(ctx, []string{"caller.go"}) + require.NoError(t, err, "optional adjacency carry must not refuse an exact current file projection") + require.NotNil(t, srv.readerFor(ctx).GetNode(caller)) + require.True(t, srv.hasRequestProjectionFact(ctx, "caller.go")) + req := mcpgo.CallToolRequest{} + req.Params.Name = "get_symbol" + req.Params.Arguments = map[string]any{"id": caller, "detail": "full"} + scope, ids, files := freshnessTargets(req.Params.Name, req) + require.Equal(t, "relations", scope) + require.Equal(t, "pending", srv.freshnessFor(ctx, scope, ids, files).state, "unsupported carry never makes relation freshness complete") + for _, edge := range srv.readerFor(ctx).GetOutEdges(caller) { + require.False(t, edge.Provisional, "unknown selected carry supplies no inherited edge proof") + } +} + +func TestSelectedProjectionLateMetadataFailureRetains(t *testing.T) { + for _, phase := range []int{2, 3} { + t.Run(string(rune('0'+phase)), func(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + entry := selectedPendingEntry(t, stack) + publishSelectedEntry(t, stack, entry, false) + ctx, view := routedEnrichmentView(t, stack) + var probe *selectedRetirementProbe + probe = &selectedRetirementProbe{Reader: view.reader, beforeMetadata: func(call int) { + if call == phase { + probe.metadataError = graph.ErrConstantProjectionStale + } + }} + view.reader = probe + view.materialized.Reader = probe + _, err := stack.srv.ensureProjected(ctx, []string{entry.graphPath}) + require.NoError(t, err) + require.GreaterOrEqual(t, probe.metadataCalls, phase, "exercise the second hash or final pre-COW validation") + require.Same(t, entry, liveEntry(pendingFileProjections, entry.absPath)) + }) + } +} + +func TestProjectedCarryForeignRecordedSiteDoesNotCarry(t *testing.T) { + srv, ctx, caller := projectedCarryFixture(t, false) + abs := filepath.Join(srv.indexer.RootPath(), "caller.go") + entry, err := srv.projectCurrentTrackedFile(t.Context(), ctx, abs, "caller.go", "caller.go") + require.NoError(t, err) + layer := graph.NewOverlayLayer() + layer.MarkFile("caller.go", false) + for _, node := range entry.layer.FileNodes("caller.go") { + layer.AddNode("caller.go", node) + } + layer.AddEdge(&graph.Edge{From: caller, To: "unresolved::Target", Kind: graph.EdgeCalls, FilePath: "foreign.go", Line: 5}) + owned := entry.committedCopy(0, entry.disk) + owned.layer = layer + visible, err := projectedFileVisibility(t.Context(), srv.graph, owned) + require.NoError(t, err) + for edge := range visible.Edges() { + require.False(t, edge.Provisional, "another recording file supplies no carry evidence for this projection") + } +} + +func TestSelectedProjectionCallerDeadlineRetainsEntry(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + entry := selectedPendingEntry(t, stack) + publishSelectedEntry(t, stack, entry, false) + ctx, view := routedEnrichmentView(t, stack) + probe := &selectedRetirementProbe{Reader: view.reader, waitMetadataContext: true} + view.reader = probe + view.materialized.Reader = probe + ctx, cancel := context.WithTimeout(ctx, 40*time.Millisecond) + defer cancel() + started := time.Now() + _, err := stack.srv.ensureProjected(ctx, []string{entry.graphPath}) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Greater(t, probe.metadataCalls, 0, "the caller's deadline interrupts actual selected proof") + require.Less(t, time.Since(started), time.Second) + require.Same(t, entry, liveEntry(pendingFileProjections, entry.absPath)) +} + +func TestProjectedCarryCallerDeadlineIsNotOptionalFallback(t *testing.T) { + srv, ctx, _ := projectedCarryFixture(t, false) + probe := &selectedRetirementProbe{Reader: srv.graph, waitNodesContext: true} + ctx = withRequestView(ctx, &requestView{reader: probe, viewRoot: srv.indexer.RootPath(), kind: requestViewKindWorktree}) + ctx, cancel := context.WithTimeout(ctx, 40*time.Millisecond) + defer cancel() + _, err := srv.ensureProjected(ctx, []string{"caller.go"}) + require.ErrorIs(t, err, context.DeadlineExceeded, "actual cancellation is never suppressed by optional carry compatibility") +} diff --git a/internal/mcp/tools_cfg.go b/internal/mcp/tools_cfg.go index 30bc863a1..fa337f2eb 100644 --- a/internal/mcp/tools_cfg.go +++ b/internal/mcp/tools_cfg.go @@ -65,10 +65,14 @@ func (s *Server) buildSymbolCFG(ctx context.Context, id string) (*symbolCFG, err if err != nil { return nil, err } - source, fromLine, _, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, 0) + ns, err := s.readNodeSource(ctx, node, absPath) if err != nil { return nil, fmt.Errorf("could not read source: %v", err) } + resolved := *node + resolved.StartLine, resolved.EndLine = ns.startLine, ns.endLine + node = &resolved + source, fromLine, _, _ := extractLinesFromContent(ns.content, ns.startLine, ns.endLine, 0) c, err := cfg.Build([]byte(source), node.Language, cfg.Options{ LineOffset: fromLine - 1, FuncName: node.Name, @@ -85,9 +89,11 @@ func (s *Server) handleGetCFG(ctx context.Context, req mcp.CallToolRequest) (*mc return mcp.NewToolResultError("id is required"), nil } - // Auto re-index stale file before querying. if parts := strings.SplitN(id, "::", 2); len(parts) == 2 { - s.ensureFreshContext(ctx, []string{parts[0]}) + ctx, err = s.ensureProjected(ctx, []string{parts[0]}) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } } sc, buildErr := s.buildSymbolCFG(ctx, id) diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index 9fab7c13b..e6d3d3d3d 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -365,8 +365,10 @@ func (s *Server) handleGetEditingContext(ctx context.Context, req mcp.CallToolRe // mode — the cause of spurious "no symbols found for file" misses. fp = s.graphRelPath(ctx, fp) - // Auto re-index stale file before querying. - s.ensureFreshContext(ctx, []string{fp}) + ctx, err = s.ensureProjected(ctx, []string{fp}) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } s.sessionFor(ctx).recordFile(fp) // Tool-call observer: credit the recent search for the symbols in @@ -377,170 +379,81 @@ func (s *Server) handleGetEditingContext(ctx context.Context, req mcp.CallToolRe callerCap := 20 calleeCap := 20 - // Fast path: when the backend implements FileEditingContext we - // take all five projections in a small fixed number of - // round-trips instead of the per-symbol GetCallers / GetCallChain - // loop. The fallback retains the previous engine-based shape so - // the in-memory backend is unaffected. - if fc, ok := fileEditingContextFor(s.readerFor(ctx)); ok { - bundle := fc.FileEditingContext(fp, []graph.NodeKind{graph.KindFunction, graph.KindMethod}) - if bundle == nil || (bundle.FileNode == nil && len(bundle.Defines) == 0) { - return mcp.NewToolResultError("no symbols found for file: " + fp), nil - } - fileNodeForScope = bundle.FileNode - if fileNodeForScope == nil && len(bundle.Defines) > 0 { - fileNodeForScope = bundle.Defines[0] - } - if !s.nodeInSessionScope(ctx, fileNodeForScope) { - return mcp.NewToolResultError("no symbols found for file: " + fp), nil - } - for _, n := range bundle.Defines { - s.frecency.Record(n.ID) - } - if bundle.FileNode != nil { - out.File = map[string]any{"id": bundle.FileNode.ID, "language": bundle.FileNode.Language} - } - for _, n := range bundle.Defines { - entry := map[string]any{ - "id": n.ID, - "kind": n.Kind, - "name": n.Name, - "start_line": n.StartLine, - } - if n.Meta != nil { - if sig, ok := n.Meta["signature"]; ok { - entry["signature"] = sig - } - } - out.Defines = append(out.Defines, entry) - } - for _, e := range bundle.Imports { - out.Imports = append(out.Imports, map[string]any{ - "id": e.To, - "external": strings.HasPrefix(e.To, "external::"), - }) - } - // Workspace-scope post-filter mirrors the legacy GetCallers / - // GetCallChain WorkspaceID gate. - sessWS, _, bound := s.sessionScope(ctx) - var opts query.QueryOptions - if bound { - opts.WorkspaceID = sessWS - } - for _, n := range bundle.CalledBy { - if bound && !opts.ScopeAllows(n) { - continue - } - if len(out.CalledBy) >= callerCap { - break - } - out.CalledBy = append(out.CalledBy, map[string]any{ - "id": n.ID, - "name": n.Name, - "file_path": n.FilePath, - "start_line": n.StartLine, - }) - } - for _, n := range bundle.Calls { - if bound && !opts.ScopeAllows(n) { - continue - } - if len(out.Calls) >= calleeCap { - break - } - out.Calls = append(out.Calls, map[string]any{ - "id": n.ID, - "name": n.Name, - "file_path": n.FilePath, - "start_line": n.StartLine, - }) - } - } else { - sg := s.engineFor(ctx).GetFileSymbols(fp) - if len(sg.Nodes) == 0 { - return mcp.NewToolResultError("no symbols found for file: " + fp), nil - } - if !s.nodeInSessionScope(ctx, sg.Nodes[0]) { - return mcp.NewToolResultError("no symbols found for file: " + fp), nil - } - sessWS, _, _ := s.sessionScope(ctx) - for _, n := range sg.Nodes { - if n.Kind == graph.KindFile { - continue - } - s.frecency.Record(n.ID) - } - for _, n := range sg.Nodes { - if n.Kind == graph.KindFile { - out.File = map[string]any{"id": n.ID, "language": n.Language} - // The same anchor the fast path keeps: without it the - // whole-file compressed view below has no node to resolve a - // path from, so every backend without a FileEditingContext - // bundle answered compress_bodies with nothing at all. - fileNodeForScope = n - break - } - } - if fileNodeForScope == nil { - fileNodeForScope = sg.Nodes[0] + bundle, bundleErr := fileEditingContextContext(ctx, s.readerFor(ctx), fp, []graph.NodeKind{graph.KindFunction, graph.KindMethod}) + if bundleErr != nil { + return mcp.NewToolResultError("get_editing_context: " + bundleErr.Error()), nil + } + + if bundle == nil || (bundle.FileNode == nil && len(bundle.Defines) == 0) { + return mcp.NewToolResultError("no symbols found for file: " + fp), nil + } + fileNodeForScope = bundle.FileNode + if fileNodeForScope == nil && len(bundle.Defines) > 0 { + fileNodeForScope = bundle.Defines[0] + } + if !s.nodeInSessionScope(ctx, fileNodeForScope) { + return mcp.NewToolResultError("no symbols found for file: " + fp), nil + } + for _, n := range bundle.Defines { + s.frecency.Record(n.ID) + } + if bundle.FileNode != nil { + out.File = map[string]any{"id": bundle.FileNode.ID, "language": bundle.FileNode.Language} + } + for _, n := range bundle.Defines { + entry := map[string]any{ + "id": n.ID, + "kind": n.Kind, + "name": n.Name, + "start_line": n.StartLine, } - for _, n := range sg.Nodes { - if n.Kind == graph.KindFile { - continue - } - entry := map[string]any{ - "id": n.ID, - "kind": n.Kind, - "name": n.Name, - "start_line": n.StartLine, - } + if n.Meta != nil { if sig, ok := n.Meta["signature"]; ok { entry["signature"] = sig } - out.Defines = append(out.Defines, entry) } - for _, e := range sg.Edges { - if e.Kind == graph.EdgeImports { - out.Imports = append(out.Imports, map[string]any{ - "id": e.To, - "external": strings.HasPrefix(e.To, "external::"), - }) - } + out.Defines = append(out.Defines, entry) + } + for _, e := range bundle.Imports { + out.Imports = append(out.Imports, map[string]any{ + "id": e.To, + "external": strings.HasPrefix(e.To, "external::"), + }) + } + // Workspace-scope post-filter mirrors the legacy GetCallers / + // GetCallChain WorkspaceID gate. + sessWS, _, bound := s.sessionScope(ctx) + var opts query.QueryOptions + if bound { + opts.WorkspaceID = sessWS + } + for _, n := range bundle.CalledBy { + if bound && !opts.ScopeAllows(n) { + continue } - callerSeen := make(map[string]bool) - for _, n := range sg.Nodes { - if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod { - callers := s.engineFor(ctx).GetCallers(n.ID, query.QueryOptions{Depth: 1, Limit: callerCap, Detail: "brief", WorkspaceID: sessWS}) - for _, cn := range callers.Nodes { - if cn.FilePath != fp && !callerSeen[cn.ID] { - callerSeen[cn.ID] = true - out.CalledBy = append(out.CalledBy, map[string]any{ - "id": cn.ID, - "name": cn.Name, - "file_path": cn.FilePath, - "start_line": cn.StartLine, - }) - } - } - } + if len(out.CalledBy) >= callerCap { + break } - callSeen := make(map[string]bool) - for _, n := range sg.Nodes { - if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod { - chain := s.engineFor(ctx).GetCallChain(n.ID, query.QueryOptions{Depth: 1, Limit: calleeCap, Detail: "brief", WorkspaceID: sessWS}) - for _, cn := range chain.Nodes { - if cn.FilePath != fp && !callSeen[cn.ID] { - callSeen[cn.ID] = true - out.Calls = append(out.Calls, map[string]any{ - "id": cn.ID, - "name": cn.Name, - "file_path": cn.FilePath, - "start_line": cn.StartLine, - }) - } - } - } + out.CalledBy = append(out.CalledBy, map[string]any{ + "id": n.ID, + "name": n.Name, + "file_path": n.FilePath, + "start_line": n.StartLine, + }) + } + for _, n := range bundle.Calls { + if bound && !opts.ScopeAllows(n) { + continue + } + if len(out.Calls) >= calleeCap { + break } + out.Calls = append(out.Calls, map[string]any{ + "id": n.ID, + "name": n.Name, + "file_path": n.FilePath, + "start_line": n.StartLine, + }) } // Optional: full-file compressed view. When compress_bodies=true, @@ -947,9 +860,11 @@ func (s *Server) handleGetSymbolSource(ctx context.Context, req mcp.CallToolRequ return mcp.NewToolResultError("id is required"), nil } - // Auto re-index stale file before querying. if parts := strings.SplitN(id, "::", 2); len(parts) == 2 { - s.ensureFreshContext(ctx, []string{parts[0]}) + ctx, err = s.ensureProjected(ctx, []string{parts[0]}) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } } node := s.engineFor(ctx).GetSymbol(id) diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index b4947d818..61d65d6ac 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -1723,9 +1723,11 @@ func (s *Server) handleGetSymbol(ctx context.Context, req mcp.CallToolRequest) ( return mcp.NewToolResultError("id is required"), nil } - // Auto re-index stale file before querying. if parts := strings.SplitN(id, "::", 2); len(parts) == 2 { - s.ensureFreshContext(ctx, []string{parts[0]}) + ctx, err = s.ensureProjected(ctx, []string{parts[0]}) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } } node := s.engineFor(ctx).GetSymbol(id) @@ -2753,8 +2755,10 @@ func (s *Server) handleGetFileSummary(ctx context.Context, req mcp.CallToolReque // mode — the cause of spurious file_not_indexed misses. fp = s.graphRelPath(ctx, fp) - // Auto re-index stale file before querying. - s.ensureFreshContext(ctx, []string{fp}) + ctx, err = s.ensureProjected(ctx, []string{fp}) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } // gcx is the high-volume agent format and only emits total_edges // in its meta header — never per-edge rows. Route gcx-only calls diff --git a/internal/mcp/tools_enhancements.go b/internal/mcp/tools_enhancements.go index 1a711569b..1b7787049 100644 --- a/internal/mcp/tools_enhancements.go +++ b/internal/mcp/tools_enhancements.go @@ -33,93 +33,8 @@ import ( "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/semantic" "github.com/zzet/gortex/internal/tokens" - "go.uber.org/zap" ) -// ensureFresh re-indexes any of the given file paths whose on-disk content has -// drifted from the indexed snapshot, so a subsequent graph read serves current -// data instead of a stale body. Returns the paths that were refreshed (capped at -// a handful per call). -// -// The self-heal is gated on IsTrackedStale, which is false for untracked, new, -// or already-current files — only a genuinely changed, already-indexed file is -// re-indexed. That gate is what makes this safe in multi-repo mode: an earlier -// version keyed the staleness check off the lone single-Indexer, whose mtime -// map is empty for cross-repo paths, so every file looked stale and the -// resulting mass re-index raced the live read surface and crashed the transport. -// Routing each path to its owning per-repo indexer keeps the check accurate and -// the work bounded to the file the caller is about to read. -func (s *Server) ensureFresh(filePaths []string) []string { - return s.ensureFreshContext(context.Background(), filePaths) -} - -func (s *Server) ensureFreshContext(ctx context.Context, filePaths []string) []string { - var refreshed []string - const limit = 5 - for _, fp := range filePaths { - if len(refreshed) >= limit { - break - } - idx, absPath := s.freshnessIndexer(fp) - if idx == nil { - continue - } - root := idx.RootPath() - if root == "" { - continue - } - rel, ok := relativeWithinRoot(root, absPath) - if !ok { - continue - } - // IsTrackedStale is false for untracked / new / current files, so - // only a known-and-changed file triggers a re-index — no mass churn. - if !idx.IsTrackedStale(rel) { - continue - } - if !s.reindexFileForRead(ctx, absPath, idx) { - s.logger.Warn("auto re-index failed", - zap.String("file", fp), - zap.String("resolved", absPath)) - continue - } - // Advance the recorded mtime so a follow-up read in the same window - // sees the file as current and skips a redundant re-index. - idx.RefreshFileMtime(absPath) - refreshed = append(refreshed, fp) - } - return refreshed -} - -// freshnessIndexer resolves a repo-prefixed, repo-relative, or absolute path to -// the indexer that owns it and the file's absolute path, for both single- and -// multi-repo daemons. In single-repo mode an active file watcher already owns -// freshness, so on-read auto-refresh stands down rather than fight it; in -// multi-repo mode each path is routed to its per-repo indexer, whose mtime map -// is populated — which is what makes the staleness check trustworthy. -func (s *Server) freshnessIndexer(fp string) (*indexer.Indexer, string) { - if s.multiIndexer != nil { - abs := fp - if !filepath.IsAbs(abs) { - if resolved := s.multiIndexer.ResolveFilePath(fp); resolved != "" { - abs = resolved - } - } - idx, _ := s.multiIndexer.IndexerForFile(abs) - return idx, abs - } - if s.indexer == nil || s.currentWatcher() != nil { - return nil, "" - } - abs := fp - if !filepath.IsAbs(abs) { - if root := s.indexer.RootPath(); root != "" { - abs = filepath.Join(root, fp) - } - } - return s.indexer, abs -} - func (s *Server) registerEnhancementTools() { // verify_change s.addTool( diff --git a/internal/mcp/tools_enhancements_test.go b/internal/mcp/tools_enhancements_test.go index 3e73e1f8a..53ff7302d 100644 --- a/internal/mcp/tools_enhancements_test.go +++ b/internal/mcp/tools_enhancements_test.go @@ -548,48 +548,6 @@ func TestPropertyCrossCommunityWarningCorrectness(t *testing.T) { // 13.7 Property 17: Auto re-index rate limit // ============================================================================ -// Feature: gortex-enhancements, Property 17: Auto re-index rate limit -// -// ensureFresh refreshes at most 5 files even when more are stale. -func TestPropertyAutoReindexRateLimit(t *testing.T) { - rapid.Check(t, func(rt *rapid.T) { - // Generate a list of stale file paths - staleCount := rapid.IntRange(1, 50).Draw(rt, "staleCount") - var staleFiles []string - for i := 0; i < staleCount; i++ { - staleFiles = append(staleFiles, "pkg/"+strings.Repeat("f", i+1)+".go") - } - - // Simulate the ensureFresh rate limiting logic - limit := 5 - var refreshed []string - for _, fp := range staleFiles { - if len(refreshed) >= limit { - break - } - // Simulate: all files are stale - refreshed = append(refreshed, fp) - } - - // Verify: at most 5 files refreshed - if len(refreshed) > 5 { - rt.Errorf("ensureFresh refreshed %d files, expected at most 5", len(refreshed)) - } - - // Verify: when staleCount > 5, exactly 5 are refreshed - if staleCount > 5 && len(refreshed) != 5 { - rt.Errorf("with %d stale files, expected exactly 5 refreshed, got %d", - staleCount, len(refreshed)) - } - - // Verify: when staleCount <= 5, all are refreshed - if staleCount <= 5 && len(refreshed) != staleCount { - rt.Errorf("with %d stale files, expected all %d refreshed, got %d", - staleCount, staleCount, len(refreshed)) - } - }) -} - // ============================================================================ // 14.1 Unit tests for tool handler edge cases // ============================================================================ @@ -692,34 +650,6 @@ func TestSingleCommunityImpactHasNullWarning(t *testing.T) { } } -// TestWatchModeActiveSkipsAutoReindex verifies that ensureFresh returns nil -// when watcher is active. -func TestWatchModeActiveSkipsAutoReindex(t *testing.T) { - srv := &Server{ - watcher: &indexer.Watcher{}, // non-nil watcher means watch mode is active - } - - result := srv.ensureFresh([]string{"pkg/file.go", "pkg/other.go"}) - if result != nil { - t.Errorf("expected nil when watcher is active, got: %v", result) - } -} - -// TestAutoReindexFailureReturnsStaleDataWithWarning verifies that when -// ensureFresh cannot re-index a file, it continues without crashing. -// The ensureFresh method logs a warning and skips the file. -func TestAutoReindexFailureReturnsStaleDataWithWarning(t *testing.T) { - // ensureFresh with nil indexer returns nil (no crash) - srv := &Server{ - indexer: nil, - watcher: nil, - } - result := srv.ensureFresh([]string{"nonexistent/file.go"}) - if result != nil { - t.Errorf("expected nil when indexer is nil, got: %v", result) - } -} - // TestBatchEditDryRunReturnsPlanOnly verifies that batch_edit with dry_run=true // returns a plan without applying changes. func TestBatchEditDryRunReturnsPlanOnly(t *testing.T) { From faee9a746e713103de8a94c8cf018070bc110c7c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 03:50:49 +0100 Subject: [PATCH 05/61] Share search projection ownership with response decoration --- internal/mcp/projected_reads.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index b1ae2c27b..07dba02a2 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -252,7 +252,7 @@ func validateRequestProjections(ctx context.Context) error { func withProjectedReadToolFacts(ctx context.Context, name string) context.Context { switch name { - case "get_symbol", "get_symbol_source", "get_file_summary", "get_editing_context", "get_cfg": + case "get_symbol", "get_symbol_source", "get_file_summary", "get_editing_context", "get_cfg", "search_symbols": return withRequestProjectionFacts(ctx) default: return ctx From c398fae660bb361f0bec765fe68b810a880444f3 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:28:02 +0100 Subject: [PATCH 06/61] test(mcp): order retirement replacement after the actual current entry --- internal/mcp/selected_projection_retirement_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/mcp/selected_projection_retirement_test.go b/internal/mcp/selected_projection_retirement_test.go index 712684acd..75479f824 100644 --- a/internal/mcp/selected_projection_retirement_test.go +++ b/internal/mcp/selected_projection_retirement_test.go @@ -242,7 +242,8 @@ func TestSelectedProjectionNewerPointerDuringNodesSurvives(t *testing.T) { entry := selectedPendingEntry(t, stack) publishSelectedEntry(t, stack, entry, false) ctx, view := routedEnrichmentView(t, stack) - newer := entry.committedCopy(9, entry.disk) + newer := entry.committedCopy(entry.commitSeq+1, entry.disk) + require.Greater(t, newer.commitSeq, entry.commitSeq, "the replacement must be newer in a full package run too") probe := &selectedRetirementProbe{Reader: view.reader, beforeNodes: func() { require.NoError(t, pendingFileProjections.publish(newer)) }} view.reader = probe view.materialized.Reader = probe From e4b2e0354dad00b3d0f25aa400b92b2dc08ba8dc Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:28:45 +0100 Subject: [PATCH 07/61] test(mcp): require refusal when retirement selection is superseded --- internal/mcp/selected_projection_retirement_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mcp/selected_projection_retirement_test.go b/internal/mcp/selected_projection_retirement_test.go index 75479f824..fceae34c8 100644 --- a/internal/mcp/selected_projection_retirement_test.go +++ b/internal/mcp/selected_projection_retirement_test.go @@ -248,7 +248,7 @@ func TestSelectedProjectionNewerPointerDuringNodesSurvives(t *testing.T) { view.reader = probe view.materialized.Reader = probe _, err := stack.srv.ensureProjected(ctx, []string{entry.graphPath}) - require.NoError(t, err) + require.ErrorIs(t, err, projectionSuperseded, "a selection superseded during checked retirement must not serve older facts") require.Same(t, newer, liveEntry(pendingFileProjections, entry.absPath), "checked selected nodes cannot remove a later registry pointer") } From 514cc22f6ecf18151f925d6aa8fd23b3690a2b2a Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 05:23:11 +0100 Subject: [PATCH 08/61] test(mcp): require current summary for a new untracked source file --- internal/mcp/untracked_projected_read_test.go | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 internal/mcp/untracked_projected_read_test.go diff --git a/internal/mcp/untracked_projected_read_test.go b/internal/mcp/untracked_projected_read_test.go new file mode 100644 index 000000000..faaf85208 --- /dev/null +++ b/internal/mcp/untracked_projected_read_test.go @@ -0,0 +1,49 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +func TestUntrackedFileReadableViaSummary(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + s.SetWatcher(mutationTestWatcher{}) + path := filepath.Join(root, "fresh.go") + raw := "package main\n\nfunc FreshRead() int { return 7 }\n\nfunc FreshSibling() {}\n" + require.NoError(t, os.WriteFile(path, []byte(raw), 0600)) + require.False(t, s.indexer.IsTracked("fresh.go"), "native-created file must not be in the tracked inventory") + require.Nil(t, s.graph.GetNode("fresh.go::FreshRead")) + req := mcplib.CallToolRequest{} + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "fresh.go", "format": "json"} + handler := s.boundToolHandler(s.wrapToolHandler(s.handleGetFileSummary)) + result, err := handler(context.Background(), req) + require.NoError(t, err) + require.False(t, result.IsError, "%s", resultText(result)) + payload := extractTextResult(t, result) + nodes, ok := payload["nodes"].([]any) + require.True(t, ok, "%v", payload) + require.Len(t, nodes, 2, "the actual summary must expose current definitions, not file_not_indexed guidance") + found := make(map[string]map[string]any) + for _, value := range nodes { + n := value.(map[string]any) + found[n["id"].(string)] = n + } + require.Contains(t, found, "fresh.go::FreshRead") + require.Equal(t, float64(3), found["fresh.go::FreshRead"]["start_line"]) + require.Equal(t, float64(3), found["fresh.go::FreshRead"]["end_line"]) + require.Contains(t, found, "fresh.go::FreshSibling") + require.Equal(t, true, payload["readback_exact"]) + require.Equal(t, "projected", payload["file_view"]) + require.Nil(t, s.graph.GetNode("fresh.go::FreshRead"), "a read cannot publish the projection into the base graph") + require.False(t, s.indexer.IsTracked("fresh.go"), "projection read-back does not mutate indexer inventory") + source := callTool(t, s, "get_symbol_source", map[string]any{"id": "fresh.go::FreshRead", "context_lines": 0, "format": "json"}) + require.False(t, source.IsError, "%s", resultText(source)) + require.Equal(t, "func FreshRead() int { return 7 }", extractTextResult(t, source)["source"]) +} From efdd21c3e40489db873032475bd9ae469ca69e68 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 05:39:39 +0100 Subject: [PATCH 09/61] test(mcp): require pending file labels during source storms --- internal/mcp/projected_storm_read_test.go | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 internal/mcp/projected_storm_read_test.go diff --git a/internal/mcp/projected_storm_read_test.go b/internal/mcp/projected_storm_read_test.go new file mode 100644 index 000000000..6b25ee4f0 --- /dev/null +++ b/internal/mcp/projected_storm_read_test.go @@ -0,0 +1,42 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +type projectedStormWatcher struct { + mutationTestWatcher + path string +} + +func (w projectedStormWatcher) StormActiveForFile(path string) bool { return path == w.path } + +func TestReadDuringStormLabelsPending(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(before, []byte("\nfunc DuringStorm() {}\n")...), 0600)) + s.SetWatcher(projectedStormWatcher{path: path}) + req := mcplib.CallToolRequest{} + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "main.go", "format": "json"} + result, err := s.wrapToolHandler(s.handleGetFileSummary)(context.Background(), req) + require.NoError(t, err) + require.False(t, result.IsError, "%s", resultText(result)) + fields := extractTextResult(t, result) + freshness, ok := fields["freshness"].(map[string]any) + require.True(t, ok, "%v", fields) + require.Equal(t, "pending", freshness["graph_freshness"]) + require.Equal(t, "storm", freshness["file_reason"]) + require.NotEqual(t, true, freshness["readback_exact"]) + require.Empty(t, pendingFileProjections.snap.Load().entries, "storm read must not publish a projection") + require.Nil(t, s.graph.GetNode("main.go::DuringStorm")) +} From 7351a5f1f361b20fe2795b117e914a33aa66cc04 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 05:44:14 +0100 Subject: [PATCH 10/61] fix(mcp): project admitted untracked files in selected checkout --- internal/mcp/projected_reads.go | 38 ++++++-- internal/mcp/untracked_projected_read_test.go | 94 ++++++++++++++++++- 2 files changed, 124 insertions(+), 8 deletions(-) diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index 07dba02a2..10b9a0191 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -12,6 +12,7 @@ import ( "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer/source" + "github.com/zzet/gortex/internal/pathguard" "github.com/zzet/gortex/internal/pathkey" ) @@ -26,7 +27,7 @@ func requestProjectionLayers(ctx context.Context) []graph.OverlayLayerReader { return layers } -// ensureProjected reads current tracked bytes without publishing graph/store +// ensureProjected reads current admitted bytes without publishing graph/store // state. Failure is explicit: an old graph must never masquerade as a current // answer after the checked file could not be projected. func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.Context, error) { @@ -66,8 +67,8 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C continue } idx, rel := s.indexerForRel(graphPath) - if idx == nil || !idx.IsTracked(rel) { - continue // untracked-file tools retain their raw-source guidance + if idx == nil { + continue } abs, err := s.resolveGraphPath(ctx, graphPath) if err != nil { @@ -119,13 +120,29 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, abs, graphPath, rel string) (*projectionEntry, error) { idx, _ := s.indexerForRel(graphPath) + root := idx.RootPath() + if selected := requestViewPathRoot(requestCtx); selected.serves(idx.RepoPrefix()) { + root = selected.root + } + readPath, confined := relativeWithinRoot(root, abs) + if !confined { + return nil, errors.New("file is outside the selected checkout") + } key := pathkey.MutationKey(abs) + // Mutation keys resolve parent aliases. Refuse an escaping parent even + // for absence/tombstone handling, which does not open a file descriptor. + if !pathguard.WithinRoot(key, pathkey.IdentityKey(pathguard.ResolveRoot(root))) { + return nil, errors.New("file parent is outside the selected checkout") + } expected := pendingFileProjections.snap.Load().entries[key] before, err := statDiskStamp(abs) if err != nil { return nil, err } if before.info == nil { + if !idx.IsTracked(rel) && expected == nil { + return nil, nil + } entry := newProjectionTombstone(abs, graphPath, idx.RepoPrefix(), 0) latest, err := statDiskStamp(abs) if err != nil || latest.info != nil || pathkey.MutationKey(abs) != key { @@ -138,15 +155,21 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, } // Reuse the confined, bounded, nonblocking regular-file reader. A FIFO, // symlink or unsupported platform is a refusal, never an unsafe Open. - files, err := source.NewFilesystemSource(filepath.Dir(abs)) + files, err := source.NewFilesystemSource(root) if err != nil { return nil, err } defer files.Close() - raw, _, err := files.ReadRegularFile(buildCtx, filepath.Base(abs), 1<<20) + raw, _, err := files.ReadRegularFile(buildCtx, filepath.ToSlash(readPath), 1<<20) if err != nil { return nil, err } + // The existing buffer admission applies the owning indexer's exclusions, + // selected checkout's ignore files, language and content gates to these + // already-confined bytes. A new file need not have a watcher inventory row. + if !idx.IsTracked(rel) && !idx.AdmitSourceSearchBuffer(buildCtx, root, abs, raw, nil) { + return nil, errors.New("file is not admitted source") + } after, err := statDiskStamp(abs) if err != nil || !before.matches(after) { return nil, errors.Join(err, errProjectionExternalWrite) @@ -175,6 +198,9 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, if err != nil { return nil, err } + if !idx.IsTracked(rel) && !projection.ParseChecked { + return nil, errors.New("untracked source parse health is unavailable") + } entry := newProjectionEntry(abs, idx.RepoPrefix(), projection, raw, 0, after) latest, err := statDiskStamp(abs) if err != nil || !after.matches(latest) { @@ -182,7 +208,7 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, } // Stat identity alone misses equal-size in-place edits with restored mtime, // including a newer version that retired while this extraction was running. - current, _, readErr := files.ReadRegularFile(buildCtx, filepath.Base(abs), 1<<20) + current, _, readErr := files.ReadRegularFile(buildCtx, filepath.ToSlash(readPath), 1<<20) finalStamp, statErr := statDiskStamp(abs) if readErr != nil || statErr != nil || !latest.matches(finalStamp) || blake3.Sum256(current) != rawHash || pathkey.MutationKey(abs) != key { return nil, errors.Join(readErr, statErr, errProjectionExternalWrite) diff --git a/internal/mcp/untracked_projected_read_test.go b/internal/mcp/untracked_projected_read_test.go index faaf85208..ab45c6b98 100644 --- a/internal/mcp/untracked_projected_read_test.go +++ b/internal/mcp/untracked_projected_read_test.go @@ -2,8 +2,10 @@ package mcp import ( "context" + "github.com/zzet/gortex/internal/pathkey" "os" "path/filepath" + "strings" "testing" mcplib "github.com/mark3labs/mcp-go/mcp" @@ -39,11 +41,99 @@ func TestUntrackedFileReadableViaSummary(t *testing.T) { require.Equal(t, float64(3), found["fresh.go::FreshRead"]["start_line"]) require.Equal(t, float64(3), found["fresh.go::FreshRead"]["end_line"]) require.Contains(t, found, "fresh.go::FreshSibling") - require.Equal(t, true, payload["readback_exact"]) - require.Equal(t, "projected", payload["file_view"]) + facts := payload["freshness"].(map[string]any) + require.Equal(t, true, facts["readback_exact"]) + require.Equal(t, "projected", facts["file_view"]) + require.Equal(t, "pending", facts["graph_freshness"]) require.Nil(t, s.graph.GetNode("fresh.go::FreshRead"), "a read cannot publish the projection into the base graph") require.False(t, s.indexer.IsTracked("fresh.go"), "projection read-back does not mutate indexer inventory") source := callTool(t, s, "get_symbol_source", map[string]any{"id": "fresh.go::FreshRead", "context_lines": 0, "format": "json"}) require.False(t, source.IsError, "%s", resultText(source)) require.Equal(t, "func FreshRead() int { return 7 }", extractTextResult(t, source)["source"]) } + +func TestUntrackedProjectionPreservesSourceAdmission(t *testing.T) { + for _, tc := range []struct { + name, path string + raw []byte + }{ + {"git_metadata", ".git/hidden.go", []byte("package main\nfunc Hidden() {}\n")}, + {"ignored", "hidden.go", []byte("package main\nfunc Hidden() {}\n")}, + {"unsupported", "hidden.zz_unknown", []byte("package main\nfunc Hidden() {}\n")}, + {"binary", "hidden.go", []byte("package main\nfunc Hidden() {}\n\x00\x00")}, + {"oversize", "hidden.go", []byte(strings.Repeat(" ", (1<<20)+1))}, + } { + t.Run(tc.name, func(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + if tc.name == "ignored" { + require.NoError(t, os.WriteFile(filepath.Join(root, ".gortexignore"), []byte("hidden.go\n"), 0600)) + } + path := filepath.Join(root, filepath.FromSlash(tc.path)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.WriteFile(path, tc.raw, 0600)) + _, err := s.ensureProjected(t.Context(), []string{tc.path}) + require.Error(t, err) + require.Nil(t, pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)]) + }) + } +} + +func TestUntrackedProjectionConfinesSelectedCheckout(t *testing.T) { + for _, kind := range []string{"leaf", "parent", "absolute"} { + t.Run(kind, func(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + other := t.TempDir() + raw := []byte("package main\nfunc ForeignSecret() {}\n") + require.NoError(t, os.WriteFile(filepath.Join(other, "secret.go"), raw, 0600)) + path := "secret.go" + switch kind { + case "leaf": + if err := os.Symlink(filepath.Join(other, "secret.go"), filepath.Join(root, path)); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + case "parent": + if err := os.Symlink(other, filepath.Join(root, "escape")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + path = "escape/secret.go" + case "absolute": + path = filepath.Join(other, "secret.go") + } + _, err := s.ensureProjected(t.Context(), []string{path}) + require.Error(t, err) + require.Empty(t, pendingFileProjections.snap.Load().entries) + }) + } +} + +func TestUntrackedProjectionUsesSelectedCheckout(t *testing.T) { + selectedProjectionRegistry(t) + s, _ := setupTestServer(t) + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "fresh.go"), []byte("package main\nfunc SelectedOnly() {}\n"), 0600)) + ctx := withRequestView(t.Context(), &requestView{kind: requestViewKindWorktree, viewRoot: root, reader: s.graph}) + ctx, err := s.ensureProjected(ctx, []string{"fresh.go"}) + require.NoError(t, err) + require.NotNil(t, s.readerFor(ctx).GetNode("fresh.go::SelectedOnly")) + require.Nil(t, s.graph.GetNode("fresh.go::SelectedOnly")) +} + +func TestUntrackedProjectionDeletionHidesPriorRead(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + path := filepath.Join(root, "fresh.go") + require.NoError(t, os.WriteFile(path, []byte("package main\nfunc BeforeDelete() {}\n"), 0600)) + ctx, err := s.ensureProjected(t.Context(), []string{"fresh.go"}) + require.NoError(t, err) + require.NotNil(t, s.readerFor(ctx).GetNode("fresh.go::BeforeDelete")) + require.NoError(t, os.Remove(path)) + next, err := s.ensureProjected(t.Context(), []string{"fresh.go"}) + require.NoError(t, err) + require.Nil(t, s.readerFor(next).GetNode("fresh.go::BeforeDelete")) + entry := pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)] + require.NotNil(t, entry) + require.True(t, entry.tombstone) + require.ErrorIs(t, validateRequestProjections(ctx), projectionSuperseded) +} From 7640c3994fd1251547cc2edc4af0a8d46780512c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 05:47:29 +0100 Subject: [PATCH 11/61] test(indexer): pin path-scoped queued storm observation --- .../indexer/watcher_storm_observation_test.go | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 internal/indexer/watcher_storm_observation_test.go diff --git a/internal/indexer/watcher_storm_observation_test.go b/internal/indexer/watcher_storm_observation_test.go new file mode 100644 index 000000000..14dddead2 --- /dev/null +++ b/internal/indexer/watcher_storm_observation_test.go @@ -0,0 +1,79 @@ +package indexer + +import ( + "context" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type stormPathObserver interface { StormActiveForFile(string) bool } + +func observesQueuedStorm(observer any, path string) bool { + scoped,ok := observer.(stormPathObserver) + return ok && scoped.StormActiveForFile(path) +} + +func TestStormActiveForFileReportsOnlyActualQueuedPath(t *testing.T) { + root,_,w := inertMutationSetWatcher(t,"storm.go","package p\nfunc Storm(){}\n") + w.config.StormQuietPeriodMs = 60000 + w.config.DebounceMs = 60000 + t.Cleanup(func(){require.NoError(t,w.Stop())}) + path := filepath.Join(root,"storm.go") + point := filepath.Join(root,"point.go") + writeTestFile(t,point,"package p\nfunc Point(){}\n") + require.False(t,observesQueuedStorm(w,path)) + w.recordInStorm(path,ChangeModified) // actual native admission and live quiet timer + _,err := w.EnqueueFileMutation(context.Background(),point) + require.NoError(t,err) + require.True(t,observesQueuedStorm(w,path),"the real queued storm path must have its scoped hint") + require.False(t,observesQueuedStorm(w,point),"a live unrelated point timer cannot borrow global storm state") + require.False(t,observesQueuedStorm(w,filepath.Join(root,"unrelated.go"))) + require.False(t,observesQueuedStorm(w,"storm.go")) + require.True(t,observesQueuedStorm(w,root+string(filepath.Separator)+"unused"+string(filepath.Separator)+".."+string(filepath.Separator)+"storm.go")) +} + +func TestStormActiveForFileDetachedWorkRemainsGenericPending(t *testing.T) { + root,_,w := inertMutationSetWatcher(t,"storm.go","package p\nfunc Storm(){}\n") + w.config.StormQuietPeriodMs = 1 + path := filepath.Join(root,"storm.go") + entered,release := make(chan struct{}),make(chan struct{}) + var unblock sync.Once + w.stormBeforeLock = func(){close(entered);<-release} + t.Cleanup(func(){unblock.Do(func(){close(release)});require.NoError(t,w.Stop())}) + w.recordInStorm(path,ChangeModified) + select {case <-entered:case <-time.After(3*time.Second):t.Fatal("real storm callback did not detach")} + state := w.TierState(path) + require.NotZero(t,state.Generation) + require.True(t,state.FileGraphAt.IsZero(),"detached work is still pending") + require.False(t,observesQueuedStorm(w,path),"queued-only hint cannot invent a mode for detached generic waiters") + unblock.Do(func(){close(release)}) + w.stormWork.Wait() + require.False(t,observesQueuedStorm(w,path)) +} + +func TestMultiWatcherStormActiveForFileUsesStartedPathOwner(t *testing.T) { + root,idx,w := inertMutationSetWatcher(t,"storm.go","package p\nfunc Storm(){}\n") + otherRoot,otherIdx,other := inertMutationSetWatcher(t,"point.go","package p\nfunc Point(){}\n") + w.config.StormQuietPeriodMs = 60000 + other.config.DebounceMs = 60000 + t.Cleanup(func(){require.NoError(t,w.Stop());require.NoError(t,other.Stop())}) + path,point := filepath.Join(root,"storm.go"),filepath.Join(otherRoot,"point.go") + w.recordInStorm(path,ChangeModified) + _,err := other.EnqueueFileMutation(context.Background(),point) + require.NoError(t,err) + mi := &MultiIndexer{repos:map[string]*RepoMetadata{"storm":{RepoPrefix:"storm",RootPath:root},"point":{RepoPrefix:"point",RootPath:otherRoot}},indexers:map[string]*Indexer{"storm":idx,"point":otherIdx}} + mw := &MultiWatcher{multi:mi,watchers:map[string]*Watcher{"storm":w,"point":other},started:map[string]bool{"storm":true,"point":true}} + require.True(t,observesQueuedStorm(mw,path)) + require.False(t,observesQueuedStorm(mw,point)) + require.False(t,observesQueuedStorm(mw,filepath.Join(t.TempDir(),"unknown.go"))) + mw.mu.Lock();mw.started["storm"]=false;mw.mu.Unlock() + require.False(t,observesQueuedStorm(mw,path)) + mw.mu.Lock();mw.started["storm"]=true;mw.stopped=true;mw.mu.Unlock() + require.False(t,observesQueuedStorm(mw,path)) + require.False(t,observesQueuedStorm((*Watcher)(nil),path)) + require.False(t,observesQueuedStorm((*MultiWatcher)(nil),path)) +} From 5479622f908d0d3553690f584da230b9163917f8 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 05:48:45 +0100 Subject: [PATCH 12/61] feat(indexer): expose path-scoped queued storm hints --- internal/indexer/watcher_storm_observation.go | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 internal/indexer/watcher_storm_observation.go diff --git a/internal/indexer/watcher_storm_observation.go b/internal/indexer/watcher_storm_observation.go new file mode 100644 index 000000000..f2a22051b --- /dev/null +++ b/internal/indexer/watcher_storm_observation.go @@ -0,0 +1,39 @@ +package indexer + +import "path/filepath" + +// StormActiveForFile reports only an exact live path in the current queued +// storm batch. It is a reason hint, not freshness or lifecycle authority. +// Once a drain detaches its batch, its waiters are generic pending work and +// this method conservatively returns false. No in-flight mode is inferred. +func (w *Watcher) StormActiveForFile(absPath string) bool { + if w == nil || !filepath.IsAbs(absPath) { return false } + absPath = filepath.Clean(absPath) + w.stormMu.Lock() + defer w.stormMu.Unlock() + if w.stormStopped || !w.stormActive { return false } + if _,queued := w.stormBatch[absPath]; !queued { return false } + generation := w.stormGenerations[absPath] + if generation == 0 { return false } + w.mu.Lock() + defer w.mu.Unlock() + if w.stopping || w.pendingGeneration[absPath] != generation { return false } + waiter,live := w.mutationWaiters[absPath][generation] + ticket := waiter.ticket + return live && ticket != nil && ticket.Path == absPath && ticket.Generation == generation && waiter.done != nil && ticket.Done == waiter.done +} + +// StormActiveForFile snapshots the path's current started owner. The child +// observation takes only that watcher's existing locks after releasing mu; +// unrelated repositories cannot supply a positive hint. +func (mw *MultiWatcher) StormActiveForFile(absPath string) bool { + if mw == nil || mw.multi == nil || !filepath.IsAbs(absPath) { return false } + absPath = filepath.Clean(absPath) + prefix := mw.multi.RepoForFile(absPath) + mw.mu.Lock() + watcher := mw.watchers[prefix] + started := mw.started[prefix] + stopped := mw.stopped + mw.mu.Unlock() + return !stopped && started && watcher != nil && watcher.StormActiveForFile(absPath) +} From fcffcc139b111b86ef913c3f3cd14fb75ba1058c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 05:49:09 +0100 Subject: [PATCH 13/61] fix(mcp): defer file projections during queued source storms --- internal/mcp/freshness_rider.go | 12 +++++++- internal/mcp/projected_reads.go | 36 +++++++++++++++++++++-- internal/mcp/projected_storm_read_test.go | 23 +++++++++++++++ internal/mcp/relation_freshness.go | 11 +++---- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 56c2f56ce..a194c81a7 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -588,7 +588,8 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st if len(snapshots) > 0 { snapshot, _ = ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot) } - gapCount := len(paths) + len(receipts) + len(snapshot.paths) + len(projectedFiles) + stormFiles := requestProjectionStormFiles(ctx) + gapCount := len(paths) + len(receipts) + len(snapshot.paths) + len(projectedFiles) + len(stormFiles) if len(snapshots) > 0 { gapCount += len(snapshots[0]) } @@ -627,6 +628,12 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st for _, file := range projectedFiles { put(file, "pending", 0) } + for _, file := range stormFiles { + put(file, "pending", 0) + if _, included := states[s.repoRelative(file)]; included { + label.fileReason = "storm" + } + } putTiers := func(path string, tiers indexer.MutationTierState) { at := tiers.RelationsAt switch scope { @@ -1090,6 +1097,9 @@ func (f relationFreshness) apply(result map[string]any) { result["complete"] = false } result["graph_freshness"] = f.state + if f.state == "pending" && f.fileReason != "" { + result["file_reason"] = f.fileReason + } scope := f.scope if scope == "" { scope = "relations" diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index 10b9a0191..fc77cb143 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -74,6 +74,10 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C if err != nil { return ctx, err } + if watcher, ok := s.currentWatcher().(interface{ StormActiveForFile(string) bool }); ok && watcher.StormActiveForFile(abs) { + markRequestProjectionStorm(ctx, graphPath) + continue + } entry, err := s.projectCurrentTrackedFile(buildCtx, ctx, abs, graphPath, rel) if err != nil { return ctx, fmt.Errorf("file graph pending for %s: %w", graphPath, err) @@ -224,8 +228,9 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, // graph layers retained by each derived context remain immutable snapshots. type requestProjectionFactsKey struct{} type requestProjectionFacts struct { - mu sync.RWMutex - entries map[string]*projectionEntry + mu sync.RWMutex + entries map[string]*projectionEntry + stormFiles map[string]struct{} // queued storm observations, not publication authority } func withRequestProjectionFacts(ctx context.Context) context.Context { @@ -235,6 +240,33 @@ func withRequestProjectionFacts(ctx context.Context) context.Context { return context.WithValue(ctx, requestProjectionFactsKey{}, &requestProjectionFacts{entries: make(map[string]*projectionEntry)}) } +// A queued storm defers disk projection for this request. Keep its observed +// pending file in the same request-local facts consumed by the tier producer; +// detached background work has no storm witness and retains generic labels. +func markRequestProjectionStorm(ctx context.Context, path string) { + facts := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + facts.mu.Lock() + defer facts.mu.Unlock() + if facts.stormFiles == nil { + facts.stormFiles = make(map[string]struct{}) + } + facts.stormFiles[path] = struct{}{} +} + +func requestProjectionStormFiles(ctx context.Context) []string { + facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + if !ok { + return nil + } + facts.mu.RLock() + defer facts.mu.RUnlock() + var paths []string + for path := range facts.stormFiles { + paths = append(paths, path) + } + return paths +} + // retainRequestProjection shares the existing request-owned freshness facts // with candidate and row selection. It adds no registry or publication owner. func retainRequestProjection(ctx context.Context, entry *projectionEntry) (context.Context, error) { diff --git a/internal/mcp/projected_storm_read_test.go b/internal/mcp/projected_storm_read_test.go index 6b25ee4f0..b6543d1b9 100644 --- a/internal/mcp/projected_storm_read_test.go +++ b/internal/mcp/projected_storm_read_test.go @@ -40,3 +40,26 @@ func TestReadDuringStormLabelsPending(t *testing.T) { require.Empty(t, pendingFileProjections.snap.Load().entries, "storm read must not publish a projection") require.Nil(t, s.graph.GetNode("main.go::DuringStorm")) } + +func TestStormReadDoesNotHideAnUnrelatedFile(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(before, []byte("\nfunc OutsideStorm() {}\n")...), 0600)) + s.SetWatcher(projectedStormWatcher{path: filepath.Join(root, "other.go")}) + req := mcplib.CallToolRequest{} + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "main.go", "format": "json"} + result, err := s.wrapToolHandler(s.handleGetFileSummary)(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "%s", resultText(result)) + fields := extractTextResult(t, result) + freshness := fields["freshness"].(map[string]any) + require.Equal(t, true, freshness["readback_exact"]) + require.Equal(t, "projected", freshness["file_view"]) + require.NotContains(t, freshness, "file_reason") + require.NotEmpty(t, pendingFileProjections.snap.Load().entries) + require.Nil(t, s.graph.GetNode("main.go::OutsideStorm")) +} diff --git a/internal/mcp/relation_freshness.go b/internal/mcp/relation_freshness.go index a8bf2e695..efc16c9f8 100644 --- a/internal/mcp/relation_freshness.go +++ b/internal/mcp/relation_freshness.go @@ -27,11 +27,12 @@ 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 { - projected bool // current request served an exact file-local projection - 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 + projected bool // current request served an exact file-local projection + fileReason string // an observed reason the request deferred a file projection + 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 } // relationWaitDeadline is when a change tool stops waiting for its relation From dda31cb242fe651fe6066cc68b485b107a4b214e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 05:49:37 +0100 Subject: [PATCH 14/61] test(indexer): reject superseded queued storm hints --- .../indexer/watcher_storm_observation_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/indexer/watcher_storm_observation_test.go b/internal/indexer/watcher_storm_observation_test.go index 14dddead2..70aefe81b 100644 --- a/internal/indexer/watcher_storm_observation_test.go +++ b/internal/indexer/watcher_storm_observation_test.go @@ -77,3 +77,18 @@ func TestMultiWatcherStormActiveForFileUsesStartedPathOwner(t *testing.T) { require.False(t,observesQueuedStorm((*Watcher)(nil),path)) require.False(t,observesQueuedStorm((*MultiWatcher)(nil),path)) } + +func TestStormActiveForFileRefusesSupersededQueuedGeneration(t *testing.T) { + root,_,w := inertMutationSetWatcher(t,"storm.go","package p\nfunc Storm(){}\n") + w.config.StormQuietPeriodMs = 60000 + w.config.DebounceMs = 60000 + t.Cleanup(func(){require.NoError(t,w.Stop())}) + path := filepath.Join(root,"storm.go") + w.recordInStorm(path,ChangeModified) + require.True(t,observesQueuedStorm(w,path)) + older := w.TierState(path).Generation + ticket,err := w.EnqueueFileMutation(context.Background(),path) + require.NoError(t,err) + require.Greater(t,ticket.Generation,older) + require.False(t,observesQueuedStorm(w,path),"an older queued storm cannot donate a reason to a newer point owner") +} From f65a9cdcd5227da68b7b0b7a5b88d0d8b08e42bb Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:05:48 +0100 Subject: [PATCH 15/61] test(mcp): require pending summary when projection budget expires --- .../mcp/projected_read_budget_pending_test.go | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 internal/mcp/projected_read_budget_pending_test.go diff --git a/internal/mcp/projected_read_budget_pending_test.go b/internal/mcp/projected_read_budget_pending_test.go new file mode 100644 index 000000000..99186fec2 --- /dev/null +++ b/internal/mcp/projected_read_budget_pending_test.go @@ -0,0 +1,104 @@ +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/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +type pendingBudgetExtractor struct { + base parser.Extractor + hold atomic.Bool + entered, release, finished chan struct{} +} + +func (e *pendingBudgetExtractor) Language() string { return "go" } +func (e *pendingBudgetExtractor) Extensions() []string { return []string{".go"} } +func (e *pendingBudgetExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult, error) { + if e.hold.CompareAndSwap(true, false) { + close(e.entered) + <-e.release + defer close(e.finished) + } + return e.base.Extract(path, raw) +} + +func TestProjectionBudgetReturnsPendingSummary(t *testing.T) { + selectedProjectionRegistry(t) + e := &pendingBudgetExtractor{base: languages.NewGoExtractor(), entered: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{})} + reg := parser.NewRegistry() + reg.Register(e) + s, root := setupTestServerWithRegistry(t, reg) + s.SetWatcher(mutationTestWatcher{}) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(before, []byte("\nfunc BeyondBudget() {}\n")...), 0600)) + e.hold.Store(true) + var once sync.Once + release := func() { once.Do(func() { close(e.release) }) } + t.Cleanup(release) + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + req := mcplib.CallToolRequest{} + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "main.go", "format": "json"} + type reply struct { + result *mcplib.CallToolResult + err error + } + response := make(chan reply, 1) + go func() { + result, err := s.wrapToolHandler(s.handleGetFileSummary)(ctx, req) + response <- reply{result, err} + }() + t.Cleanup(func() { + release() + select { + case <-e.finished: + case <-time.After(time.Second): + t.Error("owned extractor failed to finish after release") + } + }) + select { + case <-e.entered: + case <-ctx.Done(): + t.Fatal("actual projection did not enter held extractor") + } + var actual reply + select { + case actual = <-response: + case <-ctx.Done(): + release() + actual = <-response + t.Fatal("summary failed to return while projection remained held") + } + require.NoError(t, actual.err) + require.NotNil(t, actual.result) + require.False(t, actual.result.IsError, "projection budget must return a labelled graph answer: %s", resultText(actual.result)) + fields := extractTextResult(t, actual.result) + require.NotEmpty(t, fields["nodes"], "the existing graph remains useful while a file projection is unavailable") + freshness := fields["freshness"].(map[string]any) + require.Equal(t, "pending", freshness["graph_freshness"]) + require.Equal(t, "external_write", freshness["file_reason"]) + require.NotEqual(t, true, freshness["readback_exact"]) + require.Empty(t, pendingFileProjections.snap.Load().entries) + require.Nil(t, s.graph.GetNode("main.go::BeyondBudget")) + release() + select { + case <-e.finished: + case <-ctx.Done(): + t.Fatal("owned extractor did not finish") + } + require.Empty(t, pendingFileProjections.snap.Load().entries, "late extraction cannot publish a timed-out read projection") +} From d1a70450a118075dea1864f637f8df75654641f3 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:13:16 +0100 Subject: [PATCH 16/61] feat(mcp): prepare scoped store-busy facts for read responses --- internal/mcp/freshness_rider.go | 28 ++++++++++++--- internal/mcp/projected_reads.go | 47 ++++++++++++++++++++----- internal/mcp/read_pending_facts_test.go | 30 ++++++++++++++++ internal/mcp/relation_freshness.go | 1 + 4 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 internal/mcp/read_pending_facts_test.go diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index a194c81a7..fde874251 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -588,8 +588,9 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st if len(snapshots) > 0 { snapshot, _ = ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot) } - stormFiles := requestProjectionStormFiles(ctx) - gapCount := len(paths) + len(receipts) + len(snapshot.paths) + len(projectedFiles) + len(stormFiles) + deferredFiles := requestDeferredProjectionFiles(ctx) + storeBusyFiles := requestStoreBusyFiles(ctx) + gapCount := len(paths) + len(receipts) + len(snapshot.paths) + len(projectedFiles) + len(deferredFiles) + len(storeBusyFiles) if len(snapshots) > 0 { gapCount += len(snapshots[0]) } @@ -628,12 +629,28 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st for _, file := range projectedFiles { put(file, "pending", 0) } - for _, file := range stormFiles { + for file, reason := range deferredFiles { put(file, "pending", 0) if _, included := states[s.repoRelative(file)]; included { - label.fileReason = "storm" + label.fileReason = reason } } + for _, file := range storeBusyFiles { + if len(ids)+len(files) > 0 && !inScope(file) { + continue + } + if len(ids)+len(files) == 0 { + if prefix := baseNarrowedRepoPrefix(requestViewFromContext(ctx)); prefix != "" && !strings.HasPrefix(s.repoRelative(file), prefix+"/") { + continue + } + } + key := s.repoRelative(file) + prior := states[key] + if prior.state != "failed" { + states[key] = pathState{"pending", prior.generation} + } + label.storeBusy = true + } putTiers := func(path string, tiers indexer.MutationTierState) { at := tiers.RelationsAt switch scope { @@ -1100,6 +1117,9 @@ func (f relationFreshness) apply(result map[string]any) { if f.state == "pending" && f.fileReason != "" { result["file_reason"] = f.fileReason } + if f.state == "pending" && f.storeBusy { + result["reason"] = "store_busy" + } scope := f.scope if scope == "" { scope = "relations" diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index fc77cb143..88d86a6e6 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "path/filepath" "sync" "time" @@ -228,9 +229,10 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, // graph layers retained by each derived context remain immutable snapshots. type requestProjectionFactsKey struct{} type requestProjectionFacts struct { - mu sync.RWMutex - entries map[string]*projectionEntry - stormFiles map[string]struct{} // queued storm observations, not publication authority + mu sync.RWMutex + entries map[string]*projectionEntry + deferredFiles map[string]string // projection deferral observations, not publication authority + storeBusyFiles map[string]struct{} // selected store sections were unavailable } func withRequestProjectionFacts(ctx context.Context) context.Context { @@ -244,16 +246,45 @@ func withRequestProjectionFacts(ctx context.Context) context.Context { // pending file in the same request-local facts consumed by the tier producer; // detached background work has no storm witness and retains generic labels. func markRequestProjectionStorm(ctx context.Context, path string) { + markRequestProjectionDeferred(ctx, path, "storm") +} + +func markRequestProjectionDeferred(ctx context.Context, path, reason string) { + facts := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + facts.mu.Lock() + defer facts.mu.Unlock() + if facts.deferredFiles == nil { + facts.deferredFiles = make(map[string]string) + } + facts.deferredFiles[path] = reason +} + +// markRequestStoreBusy records a failed selected-store section in the same +// request facts consumed by freshnessFor. It does not authorize fallback to a +// canonical reader or certify any omitted rows. Callers pass a graph-keyed path. +func markRequestStoreBusy(ctx context.Context, path string) context.Context { + ctx = withRequestProjectionFacts(ctx) facts := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) facts.mu.Lock() defer facts.mu.Unlock() - if facts.stormFiles == nil { - facts.stormFiles = make(map[string]struct{}) + if facts.storeBusyFiles == nil { + facts.storeBusyFiles = make(map[string]struct{}) } - facts.stormFiles[path] = struct{}{} + facts.storeBusyFiles[path] = struct{}{} + return ctx +} + +func requestDeferredProjectionFiles(ctx context.Context) map[string]string { + facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + if !ok { + return nil + } + facts.mu.RLock() + defer facts.mu.RUnlock() + return maps.Clone(facts.deferredFiles) } -func requestProjectionStormFiles(ctx context.Context) []string { +func requestStoreBusyFiles(ctx context.Context) []string { facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) if !ok { return nil @@ -261,7 +292,7 @@ func requestProjectionStormFiles(ctx context.Context) []string { facts.mu.RLock() defer facts.mu.RUnlock() var paths []string - for path := range facts.stormFiles { + for path := range facts.storeBusyFiles { paths = append(paths, path) } return paths diff --git a/internal/mcp/read_pending_facts_test.go b/internal/mcp/read_pending_facts_test.go new file mode 100644 index 000000000..e6ab1f320 --- /dev/null +++ b/internal/mcp/read_pending_facts_test.go @@ -0,0 +1,30 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStoreBusyFactsRemainScopedAndKeepFileReason(t *testing.T) { + s, _ := setupTestServer(t) + ctx := markRequestStoreBusy(t.Context(), "main.go") + ctx = markRequestStoreBusy(ctx, "main.go") + outside := s.freshnessFor(ctx, "relations", nil, []string{"other.go"}) + require.Empty(t, outside.state, "a failed store section is not workspace-wide freshness evidence") + require.False(t, outside.storeBusy) + observed := s.freshnessFor(ctx, "relations", nil, []string{"main.go"}) + fields := map[string]any{} + observed.apply(fields) + require.Equal(t, "pending", fields["graph_freshness"]) + require.Equal(t, "store_busy", fields["reason"]) + require.Equal(t, false, fields["complete"]) + require.Equal(t, 1, fields["pending_file_count"], "duplicate section failures retain one graph-keyed pending file") + require.NotContains(t, fields, "readback_exact") + markRequestProjectionStorm(ctx, "main.go") + observed = s.freshnessFor(ctx, "relations", nil, []string{"main.go"}) + fields = map[string]any{} + observed.apply(fields) + require.Equal(t, "storm", fields["file_reason"]) + require.Equal(t, "store_busy", fields["reason"], "a missing store section must not erase the independently observed file deferral") +} diff --git a/internal/mcp/relation_freshness.go b/internal/mcp/relation_freshness.go index efc16c9f8..f06fd3584 100644 --- a/internal/mcp/relation_freshness.go +++ b/internal/mcp/relation_freshness.go @@ -28,6 +28,7 @@ const maxLabelledPendingFiles = 16 // its scope are still being published. The zero value is a fresh scope. type relationFreshness struct { projected bool // current request served an exact file-local projection + storeBusy bool // selected store sections were unavailable fileReason string // an observed reason the request deferred a file projection scope string state string // Empty is fresh internally; explicit responses may render fresh or unknown. From 54c68d50ec2b1acc549f67ec096e24e7a284bc13 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:19:43 +0100 Subject: [PATCH 17/61] test(mcp): cover projected source identity and read frontend contracts --- .../projected_read_frontend_contract_test.go | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 internal/mcp/projected_read_frontend_contract_test.go diff --git a/internal/mcp/projected_read_frontend_contract_test.go b/internal/mcp/projected_read_frontend_contract_test.go new file mode 100644 index 000000000..065df0fca --- /dev/null +++ b/internal/mcp/projected_read_frontend_contract_test.go @@ -0,0 +1,115 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync/atomic" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zeebo/blake3" + + "github.com/zzet/gortex/internal/pathkey" +) + +func TestReadFileCompressionKeepsCurrentProjectedID(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + s.SetWatcher(mutationTestWatcher{}) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + current := string(before) + "\nfunc FreshKeep() {\n println(\"KEEP_CURRENT_BODY\")\n println(1)\n println(2)\n println(3)\n}\n\nfunc FreshElide() {\n println(\"DROP_OTHER_BODY\")\n println(4)\n println(5)\n println(6)\n}\n" + require.NoError(t, os.WriteFile(path, []byte(current), 0600)) + req := mcplib.CallToolRequest{} + req.Params.Name = "read_file" + req.Params.Arguments = map[string]any{"path": "main.go", "compress_bodies": true, "keep": "main.go::FreshKeep", "format": "json"} + result, err := s.wrapToolHandler(s.handleReadFile)(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "%s", resultText(result)) + fields := extractTextResult(t, result) + require.Contains(t, fields["content"], "KEEP_CURRENT_BODY", "an ID must resolve against the current file projection before compression") + require.NotContains(t, fields["content"], "DROP_OTHER_BODY") + require.Contains(t, fields["kept_symbols"], "FreshKeep") + require.Nil(t, s.graph.GetNode("main.go::FreshKeep"), "read-through cannot publish current definitions into the base graph") +} + +func TestSymbolSourceRechecksSelectedHashAfterSameStampWrite(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupMoveInlineRepo(t, map[string]string{"sample.go": "package sample\nfunc Probe() int { return 0 }\n"}) + s.SetWatcher(mutationTestWatcher{}) + path := filepath.Join(root, "sample.go") + first := []byte("package sample\nfunc Probe() int { return 1 }\n") + second := []byte("package sample\nfunc Probe() int { return 2 }\n") + require.Equal(t, len(first), len(second)) + require.NoError(t, os.WriteFile(path, first, 0600)) + var changed atomic.Bool + var observed *projectionEntry + actual := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + var err error + ctx, err = s.ensureProjected(ctx, []string{"sample.go"}) + if err != nil { + return nil, err + } + if changed.CompareAndSwap(false, true) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if err := os.WriteFile(path, second, 0600); err != nil { + return nil, err + } + if err := os.Chtimes(path, info.ModTime(), info.ModTime()); err != nil { + return nil, err + } + } + result, err := s.handleGetSymbolSource(ctx, req) + if facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts); ok { + facts.mu.RLock() + observed = facts.entries[pathkey.MutationKey(path)] + facts.mu.RUnlock() + } + return result, err + } + req := mcplib.CallToolRequest{} + req.Params.Name = "get_symbol_source" + req.Params.Arguments = map[string]any{"id": "sample.go::Probe", "context_lines": 0, "format": "json"} + result, err := s.wrapToolHandler(actual)(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "one native same-stamp movement can be retried coherently: %s", resultText(result)) + fields := extractTextResult(t, result) + require.Equal(t, "func Probe() int { return 2 }", fields["source"]) + require.NotNil(t, observed) + require.Equal(t, blake3.Sum256(second), observed.rawHash, "returned bytes and selected graph/freshness must share one actual parsed-file identity") +} + +func TestRemovedProjectedSymbolHasSpecificReason(t *testing.T) { + for _, tool := range []string{"get_symbol", "get_symbol_source"} { + t.Run(tool, func(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + s.SetWatcher(mutationTestWatcher{}) + ack := callTool(t, s, "edit_file", map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func Survivor() {}", "format": "json"}) + require.False(t, ack.IsError, "%s", resultText(ack)) + require.NotNil(t, s.graph.GetNode("main.go::helper"), "prove base still contains the actually removed declaration") + handler := s.handleGetSymbol + if tool == "get_symbol_source" { + handler = s.handleGetSymbolSource + } + invoke := func(id string) *mcplib.CallToolResult { + req := mcplib.CallToolRequest{} + req.Params.Name = tool + req.Params.Arguments = map[string]any{"id": id, "format": "json"} + result, err := s.wrapToolHandler(handler)(t.Context(), req) + require.NoError(t, err) + return result + } + unknown := invoke("main.go::NeverExisted") + require.NotContains(t, resultText(unknown), "removed by pending edit", "absence alone is not evidence of removal") + removed := invoke("main.go::helper") + require.Contains(t, resultText(removed), "removed by pending edit", "the current parsed projection must explain the known removed declaration") + }) + } +} From 099ec1edbf586e25f306df65143ae301013e5cb8 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:20:10 +0100 Subject: [PATCH 18/61] fix(mcp): return qualified pending graph after projection timeout --- internal/mcp/projected_reads.go | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index 88d86a6e6..a65c4a5ba 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -12,6 +12,7 @@ import ( "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/pathguard" "github.com/zzet/gortex/internal/pathkey" @@ -44,7 +45,11 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C layers := append([]graph.OverlayLayerReader(nil), requestProjectionLayers(ctx)...) for _, graphPath := range paths { if err := buildCtx.Err(); err != nil { - return ctx, err + if ctx.Err() != nil { + return ctx, ctx.Err() + } + markRequestProjectionDeferred(ctx, graphPath, "external_write") + continue } if editor := OverlayViewFromContext(ctx); editor != nil && editor.LayerReader() != nil && editor.LayerReader().HasFile(graphPath) { // This pinned buffer completely owns the file. Disk contributes @@ -81,6 +86,10 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C } entry, err := s.projectCurrentTrackedFile(buildCtx, ctx, abs, graphPath, rel) if err != nil { + if projectionBudgetUnavailable(ctx, err) { + markRequestProjectionDeferred(ctx, graphPath, "external_write") + continue + } return ctx, fmt.Errorf("file graph pending for %s: %w", graphPath, err) } if entry != nil { @@ -88,7 +97,11 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C continue } if err := buildCtx.Err(); err != nil { - return ctx, err + if ctx.Err() != nil { + return ctx, ctx.Err() + } + markRequestProjectionDeferred(ctx, graphPath, "external_write") + continue } layer, err := projectedFileVisibility(buildCtx, s.requestSelectedBaseReader(ctx), entry) if err != nil { @@ -99,7 +112,11 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C layer = entry.layer } if err := buildCtx.Err(); err != nil { - return ctx, err + if ctx.Err() != nil { + return ctx, ctx.Err() + } + markRequestProjectionDeferred(ctx, graphPath, "external_write") + continue } ctx, err = retainRequestProjection(ctx, entry) if err != nil { @@ -123,6 +140,13 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C return ctx, nil } +// Only the private projection budget may degrade into a qualified graph +// answer. Caller cancellation, identity/parse failures and unsupported sources +// retain their refusals; no old graph gains exactness or completed-work credit. +func projectionBudgetUnavailable(caller context.Context, err error) bool { + return caller.Err() == nil && (errors.Is(err, context.DeadlineExceeded) || errors.Is(err, indexer.ErrProjectionDeadline) || errors.Is(err, indexer.ErrProjectionNoSlot)) +} + func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, abs, graphPath, rel string) (*projectionEntry, error) { idx, _ := s.indexerForRel(graphPath) root := idx.RootPath() From 7c85042d50bfe0cf843e2b92e858cf8c25361e5e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:22:48 +0100 Subject: [PATCH 19/61] style(indexer): format path-scoped storm observation donor --- internal/indexer/watcher_storm_observation.go | 58 ++++--- .../indexer/watcher_storm_observation_test.go | 155 +++++++++--------- 2 files changed, 117 insertions(+), 96 deletions(-) diff --git a/internal/indexer/watcher_storm_observation.go b/internal/indexer/watcher_storm_observation.go index f2a22051b..37e356724 100644 --- a/internal/indexer/watcher_storm_observation.go +++ b/internal/indexer/watcher_storm_observation.go @@ -7,33 +7,45 @@ import "path/filepath" // Once a drain detaches its batch, its waiters are generic pending work and // this method conservatively returns false. No in-flight mode is inferred. func (w *Watcher) StormActiveForFile(absPath string) bool { - if w == nil || !filepath.IsAbs(absPath) { return false } - absPath = filepath.Clean(absPath) - w.stormMu.Lock() - defer w.stormMu.Unlock() - if w.stormStopped || !w.stormActive { return false } - if _,queued := w.stormBatch[absPath]; !queued { return false } - generation := w.stormGenerations[absPath] - if generation == 0 { return false } - w.mu.Lock() - defer w.mu.Unlock() - if w.stopping || w.pendingGeneration[absPath] != generation { return false } - waiter,live := w.mutationWaiters[absPath][generation] - ticket := waiter.ticket - return live && ticket != nil && ticket.Path == absPath && ticket.Generation == generation && waiter.done != nil && ticket.Done == waiter.done + if w == nil || !filepath.IsAbs(absPath) { + return false + } + absPath = filepath.Clean(absPath) + w.stormMu.Lock() + defer w.stormMu.Unlock() + if w.stormStopped || !w.stormActive { + return false + } + if _, queued := w.stormBatch[absPath]; !queued { + return false + } + generation := w.stormGenerations[absPath] + if generation == 0 { + return false + } + w.mu.Lock() + defer w.mu.Unlock() + if w.stopping || w.pendingGeneration[absPath] != generation { + return false + } + waiter, live := w.mutationWaiters[absPath][generation] + ticket := waiter.ticket + return live && ticket != nil && ticket.Path == absPath && ticket.Generation == generation && waiter.done != nil && ticket.Done == waiter.done } // StormActiveForFile snapshots the path's current started owner. The child // observation takes only that watcher's existing locks after releasing mu; // unrelated repositories cannot supply a positive hint. func (mw *MultiWatcher) StormActiveForFile(absPath string) bool { - if mw == nil || mw.multi == nil || !filepath.IsAbs(absPath) { return false } - absPath = filepath.Clean(absPath) - prefix := mw.multi.RepoForFile(absPath) - mw.mu.Lock() - watcher := mw.watchers[prefix] - started := mw.started[prefix] - stopped := mw.stopped - mw.mu.Unlock() - return !stopped && started && watcher != nil && watcher.StormActiveForFile(absPath) + if mw == nil || mw.multi == nil || !filepath.IsAbs(absPath) { + return false + } + absPath = filepath.Clean(absPath) + prefix := mw.multi.RepoForFile(absPath) + mw.mu.Lock() + watcher := mw.watchers[prefix] + started := mw.started[prefix] + stopped := mw.stopped + mw.mu.Unlock() + return !stopped && started && watcher != nil && watcher.StormActiveForFile(absPath) } diff --git a/internal/indexer/watcher_storm_observation_test.go b/internal/indexer/watcher_storm_observation_test.go index 70aefe81b..8522d89ce 100644 --- a/internal/indexer/watcher_storm_observation_test.go +++ b/internal/indexer/watcher_storm_observation_test.go @@ -1,94 +1,103 @@ package indexer import ( - "context" - "path/filepath" - "sync" - "testing" - "time" + "context" + "path/filepath" + "sync" + "testing" + "time" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/require" ) -type stormPathObserver interface { StormActiveForFile(string) bool } +type stormPathObserver interface{ StormActiveForFile(string) bool } func observesQueuedStorm(observer any, path string) bool { - scoped,ok := observer.(stormPathObserver) - return ok && scoped.StormActiveForFile(path) + scoped, ok := observer.(stormPathObserver) + return ok && scoped.StormActiveForFile(path) } func TestStormActiveForFileReportsOnlyActualQueuedPath(t *testing.T) { - root,_,w := inertMutationSetWatcher(t,"storm.go","package p\nfunc Storm(){}\n") - w.config.StormQuietPeriodMs = 60000 - w.config.DebounceMs = 60000 - t.Cleanup(func(){require.NoError(t,w.Stop())}) - path := filepath.Join(root,"storm.go") - point := filepath.Join(root,"point.go") - writeTestFile(t,point,"package p\nfunc Point(){}\n") - require.False(t,observesQueuedStorm(w,path)) - w.recordInStorm(path,ChangeModified) // actual native admission and live quiet timer - _,err := w.EnqueueFileMutation(context.Background(),point) - require.NoError(t,err) - require.True(t,observesQueuedStorm(w,path),"the real queued storm path must have its scoped hint") - require.False(t,observesQueuedStorm(w,point),"a live unrelated point timer cannot borrow global storm state") - require.False(t,observesQueuedStorm(w,filepath.Join(root,"unrelated.go"))) - require.False(t,observesQueuedStorm(w,"storm.go")) - require.True(t,observesQueuedStorm(w,root+string(filepath.Separator)+"unused"+string(filepath.Separator)+".."+string(filepath.Separator)+"storm.go")) + root, _, w := inertMutationSetWatcher(t, "storm.go", "package p\nfunc Storm(){}\n") + w.config.StormQuietPeriodMs = 60000 + w.config.DebounceMs = 60000 + t.Cleanup(func() { require.NoError(t, w.Stop()) }) + path := filepath.Join(root, "storm.go") + point := filepath.Join(root, "point.go") + writeTestFile(t, point, "package p\nfunc Point(){}\n") + require.False(t, observesQueuedStorm(w, path)) + w.recordInStorm(path, ChangeModified) // actual native admission and live quiet timer + _, err := w.EnqueueFileMutation(context.Background(), point) + require.NoError(t, err) + require.True(t, observesQueuedStorm(w, path), "the real queued storm path must have its scoped hint") + require.False(t, observesQueuedStorm(w, point), "a live unrelated point timer cannot borrow global storm state") + require.False(t, observesQueuedStorm(w, filepath.Join(root, "unrelated.go"))) + require.False(t, observesQueuedStorm(w, "storm.go")) + require.True(t, observesQueuedStorm(w, root+string(filepath.Separator)+"unused"+string(filepath.Separator)+".."+string(filepath.Separator)+"storm.go")) } func TestStormActiveForFileDetachedWorkRemainsGenericPending(t *testing.T) { - root,_,w := inertMutationSetWatcher(t,"storm.go","package p\nfunc Storm(){}\n") - w.config.StormQuietPeriodMs = 1 - path := filepath.Join(root,"storm.go") - entered,release := make(chan struct{}),make(chan struct{}) - var unblock sync.Once - w.stormBeforeLock = func(){close(entered);<-release} - t.Cleanup(func(){unblock.Do(func(){close(release)});require.NoError(t,w.Stop())}) - w.recordInStorm(path,ChangeModified) - select {case <-entered:case <-time.After(3*time.Second):t.Fatal("real storm callback did not detach")} - state := w.TierState(path) - require.NotZero(t,state.Generation) - require.True(t,state.FileGraphAt.IsZero(),"detached work is still pending") - require.False(t,observesQueuedStorm(w,path),"queued-only hint cannot invent a mode for detached generic waiters") - unblock.Do(func(){close(release)}) - w.stormWork.Wait() - require.False(t,observesQueuedStorm(w,path)) + root, _, w := inertMutationSetWatcher(t, "storm.go", "package p\nfunc Storm(){}\n") + w.config.StormQuietPeriodMs = 1 + path := filepath.Join(root, "storm.go") + entered, release := make(chan struct{}), make(chan struct{}) + var unblock sync.Once + w.stormBeforeLock = func() { close(entered); <-release } + t.Cleanup(func() { unblock.Do(func() { close(release) }); require.NoError(t, w.Stop()) }) + w.recordInStorm(path, ChangeModified) + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("real storm callback did not detach") + } + state := w.TierState(path) + require.NotZero(t, state.Generation) + require.True(t, state.FileGraphAt.IsZero(), "detached work is still pending") + require.False(t, observesQueuedStorm(w, path), "queued-only hint cannot invent a mode for detached generic waiters") + unblock.Do(func() { close(release) }) + w.stormWork.Wait() + require.False(t, observesQueuedStorm(w, path)) } func TestMultiWatcherStormActiveForFileUsesStartedPathOwner(t *testing.T) { - root,idx,w := inertMutationSetWatcher(t,"storm.go","package p\nfunc Storm(){}\n") - otherRoot,otherIdx,other := inertMutationSetWatcher(t,"point.go","package p\nfunc Point(){}\n") - w.config.StormQuietPeriodMs = 60000 - other.config.DebounceMs = 60000 - t.Cleanup(func(){require.NoError(t,w.Stop());require.NoError(t,other.Stop())}) - path,point := filepath.Join(root,"storm.go"),filepath.Join(otherRoot,"point.go") - w.recordInStorm(path,ChangeModified) - _,err := other.EnqueueFileMutation(context.Background(),point) - require.NoError(t,err) - mi := &MultiIndexer{repos:map[string]*RepoMetadata{"storm":{RepoPrefix:"storm",RootPath:root},"point":{RepoPrefix:"point",RootPath:otherRoot}},indexers:map[string]*Indexer{"storm":idx,"point":otherIdx}} - mw := &MultiWatcher{multi:mi,watchers:map[string]*Watcher{"storm":w,"point":other},started:map[string]bool{"storm":true,"point":true}} - require.True(t,observesQueuedStorm(mw,path)) - require.False(t,observesQueuedStorm(mw,point)) - require.False(t,observesQueuedStorm(mw,filepath.Join(t.TempDir(),"unknown.go"))) - mw.mu.Lock();mw.started["storm"]=false;mw.mu.Unlock() - require.False(t,observesQueuedStorm(mw,path)) - mw.mu.Lock();mw.started["storm"]=true;mw.stopped=true;mw.mu.Unlock() - require.False(t,observesQueuedStorm(mw,path)) - require.False(t,observesQueuedStorm((*Watcher)(nil),path)) - require.False(t,observesQueuedStorm((*MultiWatcher)(nil),path)) + root, idx, w := inertMutationSetWatcher(t, "storm.go", "package p\nfunc Storm(){}\n") + otherRoot, otherIdx, other := inertMutationSetWatcher(t, "point.go", "package p\nfunc Point(){}\n") + w.config.StormQuietPeriodMs = 60000 + other.config.DebounceMs = 60000 + t.Cleanup(func() { require.NoError(t, w.Stop()); require.NoError(t, other.Stop()) }) + path, point := filepath.Join(root, "storm.go"), filepath.Join(otherRoot, "point.go") + w.recordInStorm(path, ChangeModified) + _, err := other.EnqueueFileMutation(context.Background(), point) + require.NoError(t, err) + mi := &MultiIndexer{repos: map[string]*RepoMetadata{"storm": {RepoPrefix: "storm", RootPath: root}, "point": {RepoPrefix: "point", RootPath: otherRoot}}, indexers: map[string]*Indexer{"storm": idx, "point": otherIdx}} + mw := &MultiWatcher{multi: mi, watchers: map[string]*Watcher{"storm": w, "point": other}, started: map[string]bool{"storm": true, "point": true}} + require.True(t, observesQueuedStorm(mw, path)) + require.False(t, observesQueuedStorm(mw, point)) + require.False(t, observesQueuedStorm(mw, filepath.Join(t.TempDir(), "unknown.go"))) + mw.mu.Lock() + mw.started["storm"] = false + mw.mu.Unlock() + require.False(t, observesQueuedStorm(mw, path)) + mw.mu.Lock() + mw.started["storm"] = true + mw.stopped = true + mw.mu.Unlock() + require.False(t, observesQueuedStorm(mw, path)) + require.False(t, observesQueuedStorm((*Watcher)(nil), path)) + require.False(t, observesQueuedStorm((*MultiWatcher)(nil), path)) } func TestStormActiveForFileRefusesSupersededQueuedGeneration(t *testing.T) { - root,_,w := inertMutationSetWatcher(t,"storm.go","package p\nfunc Storm(){}\n") - w.config.StormQuietPeriodMs = 60000 - w.config.DebounceMs = 60000 - t.Cleanup(func(){require.NoError(t,w.Stop())}) - path := filepath.Join(root,"storm.go") - w.recordInStorm(path,ChangeModified) - require.True(t,observesQueuedStorm(w,path)) - older := w.TierState(path).Generation - ticket,err := w.EnqueueFileMutation(context.Background(),path) - require.NoError(t,err) - require.Greater(t,ticket.Generation,older) - require.False(t,observesQueuedStorm(w,path),"an older queued storm cannot donate a reason to a newer point owner") + root, _, w := inertMutationSetWatcher(t, "storm.go", "package p\nfunc Storm(){}\n") + w.config.StormQuietPeriodMs = 60000 + w.config.DebounceMs = 60000 + t.Cleanup(func() { require.NoError(t, w.Stop()) }) + path := filepath.Join(root, "storm.go") + w.recordInStorm(path, ChangeModified) + require.True(t, observesQueuedStorm(w, path)) + older := w.TierState(path).Generation + ticket, err := w.EnqueueFileMutation(context.Background(), path) + require.NoError(t, err) + require.Greater(t, ticket.Generation, older) + require.False(t, observesQueuedStorm(w, path), "an older queued storm cannot donate a reason to a newer point owner") } From 2a0d06e01f40aec82347591da1d5fad533975b46 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:42:27 +0100 Subject: [PATCH 20/61] fix(mcp): keep projected read sources and identities coherent --- internal/mcp/node_source.go | 39 ++++++++++- internal/mcp/overlay.go | 4 +- internal/mcp/projected_reads.go | 34 +++++---- internal/mcp/projected_source_retry.go | 67 ++++++++++++++++++ internal/mcp/projected_symbol_missing.go | 87 ++++++++++++++++++++++++ internal/mcp/tools_coding.go | 16 +++++ internal/mcp/tools_core.go | 7 ++ internal/mcp/tools_fileops.go | 21 ++++++ 8 files changed, 258 insertions(+), 17 deletions(-) create mode 100644 internal/mcp/projected_source_retry.go create mode 100644 internal/mcp/projected_symbol_missing.go diff --git a/internal/mcp/node_source.go b/internal/mcp/node_source.go index 43a886a36..fbb74b2f5 100644 --- a/internal/mcp/node_source.go +++ b/internal/mcp/node_source.go @@ -5,7 +5,7 @@ import ( "context" "errors" "fmt" - "os" + "path/filepath" "slices" "strings" @@ -13,6 +13,7 @@ import ( "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" ) @@ -36,7 +37,7 @@ func (s *Server) nodeLinesIn(ctx context.Context, node *graph.Node, content []by if owner == nil || rel == "" { return 0, 0, fmt.Errorf("no indexer owns %s", node.FilePath) } - if owner.IndexedContentMatches(rel, content) { + if !requestViewFromContext(ctx).routed() && 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. @@ -125,10 +126,42 @@ func (s *Server) readNodeSource(ctx context.Context, node *graph.Node, absPath s if content, ok := s.overlayContentFor(ctx, absPath); ok { return nodeSource{content: content, startLine: node.StartLine, endLine: node.EndLine}, nil } - raw, err := os.ReadFile(absPath) + owner, _ := s.indexerForRel(node.FilePath) + if owner == nil { + return nodeSource{}, fmt.Errorf("no indexer owns %s", node.FilePath) + } + if requestDeferredProjectionFiles(ctx)[node.FilePath] != "" { + return nodeSource{}, errProjectedSourceDeferred + } + root, rel, key, err := selectedProjectionReadPath(ctx, owner.RootPath(), owner.RepoPrefix(), absPath) + if err != nil { + return nodeSource{}, err + } + files, err := source.NewFilesystemSource(root) + if err != nil { + return nodeSource{}, err + } + defer files.Close() + raw, _, err := files.ReadRegularFile(ctx, filepath.ToSlash(rel), 1<<20) if err != nil { return nodeSource{}, err } + var entry *projectionEntry + if facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts); ok { + facts.mu.RLock() + entry = facts.entries[key] + facts.mu.RUnlock() + } + if entry != nil { + if !liveProjectionForAcknowledgement(entry) || entry.rawHash != blake3.Sum256(raw) { + return nodeSource{}, invalidateProjectedSource(entry) + } + current := entry.layer.NodeByID(node.ID) + if current == nil { + return nodeSource{}, errSymbolRemoved + } + return nodeSource{content: string(indexer.DecodeUTF16Source(raw)), startLine: current.StartLine, endLine: current.EndLine}, nil + } start, end, err := s.nodeLinesIn(ctx, node, raw) if err != nil { return nodeSource{}, err diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index 677772b61..f35b23217 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -95,7 +95,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // of this function) so it bounds the whole middleware chain, not just the // leaf handler — the overlay build, the freshness sweep, and the response // capture below all touch the graph and can block on the same locks. - return s.boundToolHandler(s.retryFreshSymbolSearch(func(ctx context.Context, req mcp.CallToolRequest) (res *mcp.CallToolResult, retErr error) { + return s.boundToolHandler(s.retryFreshSymbolSearch(s.retryProjectedSymbolSource(func(ctx context.Context, req mcp.CallToolRequest) (res *mcp.CallToolResult, retErr error) { s.noteAcknowledgementMiddleware(ctx) // The instant the call entered the middleware: the origin a mutation's // or a fresh request's publication phases are measured from. @@ -475,7 +475,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay } } return res, hErr - })) + }))) } // refuseWithdrawnExactness is require_exact's post-handler half: it refuses an diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index a65c4a5ba..54e882a99 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -40,7 +40,11 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C if err := validateRequestProjections(ctx); err != nil { return ctx, err } - buildCtx, cancel := context.WithTimeout(ctx, projectedReadBudget) + deadline := time.Now().Add(projectedReadBudget) + if original, ok := ctx.Value(projectedSourceDeadlineKey{}).(time.Time); ok && original.Before(deadline) { + deadline = original + } + buildCtx, cancel := context.WithDeadline(ctx, deadline) defer cancel() layers := append([]graph.OverlayLayerReader(nil), requestProjectionLayers(ctx)...) for _, graphPath := range paths { @@ -147,21 +151,27 @@ func projectionBudgetUnavailable(caller context.Context, err error) bool { return caller.Err() == nil && (errors.Is(err, context.DeadlineExceeded) || errors.Is(err, indexer.ErrProjectionDeadline) || errors.Is(err, indexer.ErrProjectionNoSlot)) } -func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, abs, graphPath, rel string) (*projectionEntry, error) { - idx, _ := s.indexerForRel(graphPath) - root := idx.RootPath() - if selected := requestViewPathRoot(requestCtx); selected.serves(idx.RepoPrefix()) { +// Both projection and source slicing use this same selected-root boundary. +func selectedProjectionReadPath(ctx context.Context, root, prefix, abs string) (string, string, string, error) { + if selected := requestViewPathRoot(ctx); selected.serves(prefix) { root = selected.root } - readPath, confined := relativeWithinRoot(root, abs) - if !confined { - return nil, errors.New("file is outside the selected checkout") + rel, ok := relativeWithinRoot(root, abs) + if !ok { + return "", "", "", errors.New("file is outside the selected checkout") } key := pathkey.MutationKey(abs) - // Mutation keys resolve parent aliases. Refuse an escaping parent even - // for absence/tombstone handling, which does not open a file descriptor. if !pathguard.WithinRoot(key, pathkey.IdentityKey(pathguard.ResolveRoot(root))) { - return nil, errors.New("file parent is outside the selected checkout") + return "", "", "", errors.New("file parent is outside the selected checkout") + } + return root, rel, key, nil +} + +func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, abs, graphPath, rel string) (*projectionEntry, error) { + idx, _ := s.indexerForRel(graphPath) + root, readPath, key, err := selectedProjectionReadPath(requestCtx, idx.RootPath(), idx.RepoPrefix(), abs) + if err != nil { + return nil, err } expected := pendingFileProjections.snap.Load().entries[key] before, err := statDiskStamp(abs) @@ -365,7 +375,7 @@ func validateRequestProjections(ctx context.Context) error { func withProjectedReadToolFacts(ctx context.Context, name string) context.Context { switch name { - case "get_symbol", "get_symbol_source", "get_file_summary", "get_editing_context", "get_cfg", "search_symbols": + case "get_symbol", "get_symbol_source", "get_file_summary", "get_editing_context", "get_cfg", "search_symbols", "read_file": return withRequestProjectionFacts(ctx) default: return ctx diff --git a/internal/mcp/projected_source_retry.go b/internal/mcp/projected_source_retry.go new file mode 100644 index 000000000..65213c46a --- /dev/null +++ b/internal/mcp/projected_source_retry.go @@ -0,0 +1,67 @@ +package mcp + +import ( + "context" + "errors" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" +) + +var errProjectedSourceMoved = errors.New("selected source bytes changed during the read") +var errProjectedSourceDeferred = errors.New("source is pending after the projection budget expired") + +func invalidateProjectedSource(entry *projectionEntry) error { + entry.suspect.Store(true) + pendingFileProjections.drop(entry) + return errProjectedSourceMoved +} + +type projectedSourceDeadlineKey struct{} + +// Only this audited source read retries, once, for a typed byte mismatch. Each +// attempt runs the whole middleware with fresh facts/layers and releases its +// leases before the next; the first attempt's projection deadline is retained. +func (s *Server) retryProjectedSymbolSource(attempt mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + legacy, known := s.legacyToolName(&req) + if !known || legacy != "get_symbol_source" { + return attempt(ctx, req) + } + ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, time.Now().Add(projectedReadBudget)) + for ordinal := 0; ordinal < 2; ordinal++ { + copy := req + copy.Params.Arguments = cloneFreshSymbolArguments(req.GetArguments()) + result, err := runProjectedSourceAttempt(ctx, copy, attempt) + if !errors.Is(err, errProjectedSourceMoved) { + return result, err + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + // A caller-owned selection cannot be rebound by this nested invocation. + if ordinal == 1 || ctx.Value(requestProjectionFactsKey{}) != nil { + return mcplib.NewToolResultError(errProjectedSourceMoved.Error()), nil + } + } + panic("unreachable source retry") + } +} + +func runProjectedSourceAttempt(ctx context.Context, req mcplib.CallToolRequest, attempt mcpserver.ToolHandlerFunc) (*mcplib.CallToolResult, error) { + state, _ := ctx.Value(freshSymbolAttemptKey{}).(*freshSymbolAttempt) + if state == nil { + return runFreshSymbolAttempt(ctx, req, &freshSymbolAttempt{}, attempt) + } + accepted, finish := len(state.accepted), len(state.finish) + result, err := attempt(ctx, req) + if errors.Is(err, errProjectedSourceMoved) { + state.accepted = state.accepted[:accepted] + for _, release := range state.finish[finish:] { + release() + } + state.finish = state.finish[:finish] + } + return result, err +} diff --git a/internal/mcp/projected_symbol_missing.go b/internal/mcp/projected_symbol_missing.go new file mode 100644 index 000000000..fccb763bf --- /dev/null +++ b/internal/mcp/projected_symbol_missing.go @@ -0,0 +1,87 @@ +package mcp + +import ( + "context" + "path/filepath" + "strings" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/zeebo/blake3" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer/source" + "github.com/zzet/gortex/internal/pathkey" +) + +// Absence in a projection is not by itself evidence of removal. The same +// selected checked base must positively contain the requested, visible ID. +func (s *Server) removedByPendingProjection(ctx context.Context, req mcplib.CallToolRequest, id string) (bool, error) { + file, _, ok := strings.Cut(id, "::") + if !ok { + return false, nil + } + abs, err := s.resolveGraphPath(ctx, file) + if err != nil { + return false, nil + } + facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + if !ok { + return false, nil + } + facts.mu.RLock() + entry := facts.entries[pathkey.MutationKey(abs)] + facts.mu.RUnlock() + if entry == nil || entry.graphPath != file || !liveProjectionForAcknowledgement(entry) || entry.layer.NodeByID(id) != nil { + return false, nil + } + if !entry.tombstone { + owner, _ := s.indexerForRel(file) + if owner == nil { + return false, nil + } + root, rel, key, err := selectedProjectionReadPath(ctx, owner.RootPath(), owner.RepoPrefix(), abs) + if err != nil || key != entry.key { + return false, nil + } + files, err := source.NewFilesystemSource(root) + if err != nil { + return false, err + } + raw, _, err := files.ReadRegularFile(ctx, filepath.ToSlash(rel), 1<<20) + files.Close() + if err != nil { + return false, err + } + if blake3.Sum256(raw) != entry.rawHash { + return false, invalidateProjectedSource(entry) + } + } + budget, cancel := context.WithTimeout(ctx, 30*time.Millisecond) + defer cancel() + rows, err := graph.ContractFileNodesContext(budget, s.requestSelectedBaseReader(ctx), entry.repoPrefix, []string{file}) + if err != nil { + return false, nil + } + scope, refused := s.resolveScope(ctx, req, toolIntentForName(req.Params.Name)) + if refused != nil { + return false, nil + } + for _, node := range rows[file] { + if node != nil && node.ID == id && node.FilePath == file && s.nodeInSessionScope(ctx, node) && s.contractScopeAllowsNode(ctx, scope, node) && liveProjectionForAcknowledgement(entry) { + return true, nil + } + } + return false, nil +} + +func removedProjectedSymbolGuidance(id string, isError bool) *mcplib.CallToolResult { + result := newRecoverableResult(RecoverableGuidance{ + Condition: ErrCodeSymbolNotFound, + Message: "symbol not found: removed by pending edit", + SuggestedTools: []string{"get_file_summary", "search_symbols"}, + Data: map[string]any{"id": id, "reason": "removed by pending edit"}, + }) + result.IsError = isError + return result +} diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index 924b1a64d..d9cd8de30 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -869,6 +869,16 @@ func (s *Server) handleGetSymbolSource(ctx context.Context, req mcp.CallToolRequ node := s.engineFor(ctx).GetSymbol(id) if node == nil { + removed, err := s.removedByPendingProjection(ctx, req, id) + if errors.Is(err, errProjectedSourceMoved) { + return nil, err + } + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if removed { + return removedProjectedSymbolGuidance(id, true), nil + } return mcp.NewToolResultError("symbol not found: " + id + s.suggestSymbolIDs(ctx, id)), nil } // A by-id fetch must not cross the session's workspace boundary — @@ -939,6 +949,12 @@ func (s *Server) handleGetSymbolSource(ctx context.Context, req mcp.CallToolRequ } ns, err := s.readNodeSource(ctx, node, absPath) if err != nil { + if errors.Is(err, errProjectedSourceMoved) { + return nil, err + } + if errors.Is(err, errProjectedSourceDeferred) { + return s.respondJSONOrTOON(ctx, req, map[string]any{"id": node.ID, "source_state": "pending", "source_reason": "external_write", "complete": false}) + } return mcp.NewToolResultError(fmt.Sprintf("could not read source: %v", err)), nil } resolved := *node diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index 61d65d6ac..b4f089a9b 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -1732,6 +1732,13 @@ func (s *Server) handleGetSymbol(ctx context.Context, req mcp.CallToolRequest) ( node := s.engineFor(ctx).GetSymbol(id) if node == nil { + removed, err := s.removedByPendingProjection(ctx, req, id) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if removed { + return removedProjectedSymbolGuidance(id, false), nil + } return symbolNotFoundGuidance(id), nil } diff --git a/internal/mcp/tools_fileops.go b/internal/mcp/tools_fileops.go index 1e114af45..a4f6a98df 100644 --- a/internal/mcp/tools_fileops.go +++ b/internal/mcp/tools_fileops.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "errors" "fmt" + "github.com/zeebo/blake3" "io" "math" "os" @@ -21,6 +22,7 @@ import ( "github.com/zzet/gortex/internal/elide" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/tokens" ) @@ -1679,6 +1681,7 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* // indexer extracted (#846); the indexer holds symbols from the // decoded file, so serving raw NUL-interleaved bytes makes // discovery and reads disagree. + diskContent = b b, utf16Decoded = decodedSourceForRead(b) content = b } @@ -1706,6 +1709,24 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* } else { language = s.detectLanguageForPath(ctx, absPath, relPath) } + // Graph-dependent compression must use the same current file as its + // source bytes. Ordinary raw reads and unsupported eliders stay untouched. + if req.GetBool("compress_bodies", false) && req.GetString("keep", "") != "" && elide.IsSupported(language) && sourceRequestView(ctx) == nil && refViewFilesFor(ctx) == nil && !installedSkillPath(rawPath) { + ctx, err = s.ensureProjected(ctx, []string{relPath}) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if !servedFromOverlay { + if facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts); ok { + facts.mu.RLock() + entry := facts.entries[pathkey.MutationKey(absPath)] + facts.mu.RUnlock() + if entry != nil && (!liveProjectionForAcknowledgement(entry) || entry.rawHash != blake3.Sum256(diskContent)) { + return mcp.NewToolResultError(errProjectedSourceMoved.Error()), nil + } + } + } + } // Tool-call observer: credit the recent search for the symbols in // the file the agent is reading. var sg *query.SubGraph From a7dbfeb397bdc916fe7d9fc3a8c2e4dfc3d251d9 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:46:52 +0100 Subject: [PATCH 21/61] test(mcp): require one nonwaiting admission for new external projections --- .../mcp/read_projection_admission_test.go | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 internal/mcp/read_projection_admission_test.go diff --git a/internal/mcp/read_projection_admission_test.go b/internal/mcp/read_projection_admission_test.go new file mode 100644 index 000000000..53d57920d --- /dev/null +++ b/internal/mcp/read_projection_admission_test.go @@ -0,0 +1,78 @@ +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" + "github.com/zzet/gortex/internal/pathkey" +) + +func TestTrackedExternalReadAdmitsOnceWithoutWaiting(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + var admitted atomic.Int64 + held := make(chan indexer.MutationResult) + defer close(held) + s.SetWatcher(mutationTestWatcher{scheduled: &admitted, done: held}) + require.NoError(t, os.WriteFile(path, append(before, []byte("\nfunc ExternalRead() {}\n")...), 0600)) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + req := mcplib.CallToolRequest{} + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "main.go", "format": "json"} + handler := s.wrapToolHandler(s.handleGetFileSummary) + result, err := handler(ctx, req) + require.NoError(t, err) + require.False(t, result.IsError, "read must return before its admitted ticket completes: %s", resultText(result)) + require.EqualValues(t, 1, admitted.Load()) + require.Nil(t, s.graph.GetNode("main.go::ExternalRead")) + entry := pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)] + require.NotNil(t, entry) + require.Zero(t, entry.commitSeq, "a read-built projection is not a physical source commit") + if s.mutationCommits != nil { + _, committed := s.mutationCommits.recentForPath(path) + require.False(t, committed) + } + result, err = handler(ctx, req) + require.NoError(t, err) + require.False(t, result.IsError, "%s", resultText(result)) + require.EqualValues(t, 1, admitted.Load(), "reusing the same accepted cache version must not queue again") +} + +func TestReadProjectionDoesNotAdmitWithoutPublicationOrAcrossCheckouts(t *testing.T) { + for _, selected := range []bool{false, true} { + t.Run(map[bool]string{false: "disabled", true: "selected_sibling"}[selected], func(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + var admitted atomic.Int64 + s.SetWatcher(mutationTestWatcher{scheduled: &admitted}) + ctx := t.Context() + if selected { + root = t.TempDir() + ctx = withRequestView(ctx, &requestView{kind: requestViewKindWorktree, viewRoot: root, reader: s.graph}) + } else { + pendingFileProjections.enabled = false + } + require.NoError(t, os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\nfunc SelectedOnly() {}\n"), 0600)) + next, err := s.ensureProjected(ctx, []string{"main.go"}) + if selected { + require.NoError(t, err) + require.NotNil(t, s.readerFor(next).GetNode("main.go::SelectedOnly")) + } else { + require.Error(t, err) + } + require.Zero(t, admitted.Load(), "no canonical-sibling admission or admission without accepted publication") + }) + } +} From 500ff6243feb5287bd767b8dc2a09c4b2a8c8fc7 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:48:32 +0100 Subject: [PATCH 22/61] test(mcp): inspect the existing value-owned mutation ledger --- internal/mcp/read_projection_admission_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/mcp/read_projection_admission_test.go b/internal/mcp/read_projection_admission_test.go index 53d57920d..6a568bbd2 100644 --- a/internal/mcp/read_projection_admission_test.go +++ b/internal/mcp/read_projection_admission_test.go @@ -40,10 +40,8 @@ func TestTrackedExternalReadAdmitsOnceWithoutWaiting(t *testing.T) { entry := pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)] require.NotNil(t, entry) require.Zero(t, entry.commitSeq, "a read-built projection is not a physical source commit") - if s.mutationCommits != nil { - _, committed := s.mutationCommits.recentForPath(path) - require.False(t, committed) - } + _, committed := s.mutationCommits.recentForPath(path) + require.False(t, committed) result, err = handler(ctx, req) require.NoError(t, err) require.False(t, result.IsError, "%s", resultText(result)) From 84e6983b687cbb43086ae0da25bc2e07aaee14fa Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:51:37 +0100 Subject: [PATCH 23/61] fix(mcp): preserve the source retry budget and original facts --- internal/mcp/node_source.go | 13 ++++ .../projected_read_frontend_contract_test.go | 66 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/internal/mcp/node_source.go b/internal/mcp/node_source.go index fbb74b2f5..92a375d88 100644 --- a/internal/mcp/node_source.go +++ b/internal/mcp/node_source.go @@ -8,6 +8,7 @@ import ( "path/filepath" "slices" "strings" + "time" "github.com/zeebo/blake3" @@ -123,6 +124,12 @@ type nodeSource struct { // 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) { + caller := ctx + if deadline, ok := ctx.Value(projectedSourceDeadlineKey{}).(time.Time); ok { + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, deadline) + defer cancel() + } if content, ok := s.overlayContentFor(ctx, absPath); ok { return nodeSource{content: content, startLine: node.StartLine, endLine: node.EndLine}, nil } @@ -144,6 +151,9 @@ func (s *Server) readNodeSource(ctx context.Context, node *graph.Node, absPath s defer files.Close() raw, _, err := files.ReadRegularFile(ctx, filepath.ToSlash(rel), 1<<20) if err != nil { + if caller.Err() == nil && errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nodeSource{}, errProjectedSourceDeferred + } return nodeSource{}, err } var entry *projectionEntry @@ -164,6 +174,9 @@ func (s *Server) readNodeSource(ctx context.Context, node *graph.Node, absPath s } start, end, err := s.nodeLinesIn(ctx, node, raw) if err != nil { + if caller.Err() == nil && errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nodeSource{}, errProjectedSourceDeferred + } return nodeSource{}, err } return nodeSource{content: string(indexer.DecodeUTF16Source(raw)), startLine: start, endLine: end}, nil diff --git a/internal/mcp/projected_read_frontend_contract_test.go b/internal/mcp/projected_read_frontend_contract_test.go index 065df0fca..3166fd287 100644 --- a/internal/mcp/projected_read_frontend_contract_test.go +++ b/internal/mcp/projected_read_frontend_contract_test.go @@ -2,6 +2,8 @@ package mcp import ( "context" + "errors" + "fmt" "os" "path/filepath" "sync/atomic" @@ -47,6 +49,7 @@ func TestSymbolSourceRechecksSelectedHashAfterSameStampWrite(t *testing.T) { require.NoError(t, os.WriteFile(path, first, 0600)) var changed atomic.Bool var observed *projectionEntry + var originalContext context.Context actual := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { var err error ctx, err = s.ensureProjected(ctx, []string{"sample.go"}) @@ -54,6 +57,7 @@ func TestSymbolSourceRechecksSelectedHashAfterSameStampWrite(t *testing.T) { return nil, err } if changed.CompareAndSwap(false, true) { + originalContext = ctx info, err := os.Stat(path) if err != nil { return nil, err @@ -83,6 +87,14 @@ func TestSymbolSourceRechecksSelectedHashAfterSameStampWrite(t *testing.T) { require.Equal(t, "func Probe() int { return 2 }", fields["source"]) require.NotNil(t, observed) require.Equal(t, blake3.Sum256(second), observed.rawHash, "returned bytes and selected graph/freshness must share one actual parsed-file identity") + require.Error(t, validateRequestProjections(originalContext), "released or superseded original contexts remain unusable") + originalFacts := originalContext.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + originalFacts.mu.RLock() + originalEntry := originalFacts.entries[pathkey.MutationKey(path)] + originalFacts.mu.RUnlock() + require.NotSame(t, observed, originalEntry, "whole-attempt retry cannot rebind the original facts") + require.Equal(t, blake3.Sum256(first), originalEntry.rawHash) + require.False(t, liveProjectionForAcknowledgement(originalEntry)) } func TestRemovedProjectedSymbolHasSpecificReason(t *testing.T) { @@ -113,3 +125,57 @@ func TestRemovedProjectedSymbolHasSpecificReason(t *testing.T) { }) } } + +func TestSymbolSourceRetryIsBoundedAndCancellationDoesNotReplay(t *testing.T) { + for _, cancelFirst := range []bool{false, true} { + t.Run(fmt.Sprintf("cancel_%v", cancelFirst), func(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupMoveInlineRepo(t, map[string]string{"sample.go": "package sample\nfunc Probe() int { return 0 }\n"}) + path := filepath.Join(root, "sample.go") + require.NoError(t, os.WriteFile(path, []byte("package sample\nfunc Probe() int { return 1 }\n"), 0600)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var calls atomic.Int64 + actual := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + var err error + ctx, err = s.ensureProjected(ctx, []string{"sample.go"}) + if err != nil { + return nil, err + } + ordinal := calls.Add(1) + info, err := os.Stat(path) + if err != nil { + return nil, err + } + next := []byte(fmt.Sprintf("package sample\nfunc Probe() int { return %d }\n", ordinal+1)) + if err := os.WriteFile(path, next, 0600); err != nil { + return nil, err + } + if err := os.Chtimes(path, info.ModTime(), info.ModTime()); err != nil { + return nil, err + } + result, err := s.handleGetSymbolSource(ctx, req) + if cancelFirst { + cancel() + } + return result, err + } + req := mcplib.CallToolRequest{} + req.Params.Name = "get_symbol_source" + req.Params.Arguments = map[string]any{"id": "sample.go::Probe", "context_lines": 0, "format": "json"} + // Audit the retry controller over the actual source handler synchronously; + // the public middleware race is covered by the positive test above. + result, err := s.retryProjectedSymbolSource(actual)(ctx, req) + if cancelFirst { + require.True(t, errors.Is(err, context.Canceled) || result != nil && result.IsError) + require.EqualValues(t, 1, calls.Load()) + } else { + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.IsError, "a second actual movement must refuse, not retry forever") + require.EqualValues(t, 2, calls.Load()) + require.NotContains(t, resultText(result), "readback_exact") + } + }) + } +} From 1f1b641d6c3cb072ad40332bc5b85122c95212a1 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:21:37 +0100 Subject: [PATCH 24/61] fix(mcp): keep projected file rows when store budget expires --- internal/mcp/contract_core_editing_context.go | 39 +++++++++ internal/mcp/store_budget_handler_test.go | 79 +++++++++++++++++++ internal/mcp/tools_coding.go | 12 ++- 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 internal/mcp/store_budget_handler_test.go diff --git a/internal/mcp/contract_core_editing_context.go b/internal/mcp/contract_core_editing_context.go index be7907f34..3efe52f4c 100644 --- a/internal/mcp/contract_core_editing_context.go +++ b/internal/mcp/contract_core_editing_context.go @@ -54,6 +54,9 @@ func fileEditingContextContext(ctx context.Context, reader graph.Reader, path st return nil }) if err != nil { + if deadlineErr := ctx.Err(); deadlineErr != nil { + return nil, deadlineErr + } return nil, err } return result, nil @@ -116,3 +119,39 @@ func (r *editingContextCoreRows) GetOutEdgesByNodeIDs(ids []string) map[string][ func (r *editingContextCoreRows) GetInEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { return r.filterBatch(r.FileEditingContextRows.GetInEdgesByNodeIDs(ids)) } + +// requestLocalEditingContext borrows only the request's already captured +// in-memory file layer. It never refetches the selected store, parses bytes, +// or turns unavailable cross-file relations into an apparently complete set. +func requestLocalEditingContext(ctx context.Context, path string) *graph.FileEditingContextResult { + var selected *graph.OverlayLayer + for _, candidate := range requestProjectionLayers(ctx) { + if layer, ok := candidate.(*graph.OverlayLayer); ok && layer.HasFile(path) { + selected = layer + } + } + if editor := OverlayViewFromContext(ctx); editor != nil { + if layer := editor.Layer(); layer != nil && layer.HasFile(path) { + selected = layer + } + } + result := &graph.FileEditingContextResult{} + if selected == nil { + return result + } + for _, node := range selected.FileNodes(path) { + if node.Kind == graph.KindFile { + result.FileNode = node + } else { + result.Defines = append(result.Defines, node) + } + } + if result.FileNode != nil { + for _, edge := range selected.OutEdges(result.FileNode.ID) { + if edge != nil && edge.Kind == graph.EdgeImports { + result.Imports = append(result.Imports, edge) + } + } + } + return result +} diff --git a/internal/mcp/store_budget_handler_test.go b/internal/mcp/store_budget_handler_test.go new file mode 100644 index 000000000..935c8dd74 --- /dev/null +++ b/internal/mcp/store_budget_handler_test.go @@ -0,0 +1,79 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "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/query" +) + +type handlerBudgetStore struct { + graph.Store + budgetRead bool + legacyReads int +} + +func (s *handlerBudgetStore) WithFileEditingContextReader(ctx context.Context, _ func(context.Context, graph.FileEditingContextRows) error) error { + deadline, ok := ctx.Deadline() + s.budgetRead = ok && time.Until(deadline) <= 30*time.Millisecond + <-ctx.Done() + return ctx.Err() +} + +func (s *handlerBudgetStore) GetFileNodes(path string) []*graph.Node { + s.legacyReads++ + return s.Store.GetFileNodes(path) +} + +func TestEditingContextStoreDeadlineKeepsProjectedLocalRowsPending(t *testing.T) { + for _, fileReason := range []string{"", "storm", "external_write"} { + t.Run("file_reason_"+fileReason, func(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(raw, []byte("\nfunc StoreBusyLocal() {}\n")...), 0600)) + ctx, err := s.ensureProjected(t.Context(), []string{"main.go"}) + require.NoError(t, err) + require.Len(t, requestProjectionLayers(ctx), 1) + if fileReason != "" { + markRequestProjectionDeferred(ctx, "main.go", fileReason) + } + store := &handlerBudgetStore{Store: s.graph} + s.graph = store + s.engine = query.NewEngine(store) + req := mcplib.CallToolRequest{} + req.Params.Name = "get_editing_context" + req.Params.Arguments = map[string]any{"path": "main.go", "format": "json"} + result, err := s.handleGetEditingContext(ctx, req) + require.NoError(t, err) + require.False(t, result.IsError, "a store deadline must retain checked local rows: %s", resultText(result)) + fields := extractTextResult(t, result) + found := false + for _, value := range fields["defines"].([]any) { + if value.(map[string]any)["name"] == "StoreBusyLocal" { + found = true + } + } + require.True(t, found, "the actual handler lost its already selected file-local projection") + require.True(t, store.budgetRead, "the selected store must receive the existing 30ms budget") + require.Zero(t, store.legacyReads, "timeout must not fall back to contextless store reads") + // Apply the SAME existing producer result to this actual leaf response. + // The test invents no alternate freshness fields or facts producer. + s.freshnessFor(ctx, "relations", nil, []string{"main.go"}).apply(fields) + require.Equal(t, "pending", fields["graph_freshness"]) + require.Equal(t, "store_busy", fields["reason"]) + require.Equal(t, false, fields["complete"]) + if fileReason != "" { + require.Equal(t, fileReason, fields["file_reason"]) + } + }) + } +} diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index d9cd8de30..bf6b4943c 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -380,18 +380,22 @@ func (s *Server) handleGetEditingContext(ctx context.Context, req mcp.CallToolRe calleeCap := 20 bundle, bundleErr := fileEditingContextContext(ctx, s.readerFor(ctx), fp, []graph.NodeKind{graph.KindFunction, graph.KindMethod}) - if bundleErr != nil { + storeBusy := errors.Is(bundleErr, context.DeadlineExceeded) && ctx.Err() == nil + if storeBusy { + ctx = markRequestStoreBusy(ctx, fp) + bundle = requestLocalEditingContext(ctx, fp) + } else if bundleErr != nil { return mcp.NewToolResultError("get_editing_context: " + bundleErr.Error()), nil } - if bundle == nil || (bundle.FileNode == nil && len(bundle.Defines) == 0) { + if !storeBusy && (bundle == nil || (bundle.FileNode == nil && len(bundle.Defines) == 0)) { return mcp.NewToolResultError("no symbols found for file: " + fp), nil } fileNodeForScope = bundle.FileNode if fileNodeForScope == nil && len(bundle.Defines) > 0 { fileNodeForScope = bundle.Defines[0] } - if !s.nodeInSessionScope(ctx, fileNodeForScope) { + if fileNodeForScope != nil && !s.nodeInSessionScope(ctx, fileNodeForScope) { return mcp.NewToolResultError("no symbols found for file: " + fp), nil } for _, n := range bundle.Defines { @@ -533,7 +537,7 @@ func (s *Server) handleGetEditingContext(ctx context.Context, req mcp.CallToolRe // invalidates the cached entry on the caller side. etag = computeETag([2]any{out, sourceCompressed}) } - if ifNoneMatch := req.GetString("if_none_match", ""); ifNoneMatch != "" && ifNoneMatch == etag { + if ifNoneMatch := req.GetString("if_none_match", ""); !storeBusy && ifNoneMatch != "" && ifNoneMatch == etag { return notModifiedResult(etag), nil } From cdfb61af33d1a142e058ecc5fbff8ff2a2325dfc Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:31:13 +0100 Subject: [PATCH 25/61] fix(mcp): bound summary store reads and preserve pending partials --- internal/mcp/contract_core_editing_context.go | 128 ++++++++++++++++-- .../mcp/store_budget_summary_handler_test.go | 118 ++++++++++++++++ internal/mcp/tools_core.go | 39 +++--- 3 files changed, 256 insertions(+), 29 deletions(-) create mode 100644 internal/mcp/store_budget_summary_handler_test.go diff --git a/internal/mcp/contract_core_editing_context.go b/internal/mcp/contract_core_editing_context.go index 3efe52f4c..516e306f0 100644 --- a/internal/mcp/contract_core_editing_context.go +++ b/internal/mcp/contract_core_editing_context.go @@ -2,9 +2,11 @@ package mcp import ( "context" + "sort" "time" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" ) // contractCoreEditingContext serves the get_editing_context fast path through @@ -37,6 +39,20 @@ func fileEditingContextFor(reader graph.Reader) (graph.FileEditingContext, bool) // reader's checked scope. A refused or canceled scope never falls through to // errorless per-symbol reads. The RepoView lease remains owned by the request. func fileEditingContextContext(ctx context.Context, reader graph.Reader, path string, kinds []graph.NodeKind) (*graph.FileEditingContextResult, error) { + var result *graph.FileEditingContextResult + err := withFileStoreRows(ctx, reader, func(rows graph.FileEditingContextRows) error { + result = graph.FileEditingContextOf(rows, path, kinds) + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +// withFileStoreRows gives every section of one file response the SAME 30ms +// cancellable, coherent selected-store scope. No legacy API is a fallback. +func withFileStoreRows(ctx context.Context, reader graph.Reader, consume func(graph.FileEditingContextRows) error) error { ctx, cancel := context.WithTimeout(ctx, 30*time.Millisecond) defer cancel() selected := reader @@ -45,21 +61,16 @@ func fileEditingContextContext(ctx context.Context, reader graph.Reader, path st core = wrapped.contractCore() selected = core.Reader } - var result *graph.FileEditingContextResult err := graph.WithFileEditingContextReader(ctx, selected, func(_ context.Context, rows graph.FileEditingContextRows) error { if core != nil { rows = &editingContextCoreRows{FileEditingContextRows: rows, core: core} } - result = graph.FileEditingContextOf(rows, path, kinds) - return nil + return consume(rows) }) - if err != nil { - if deadlineErr := ctx.Err(); deadlineErr != nil { - return nil, deadlineErr - } - return nil, err + if err != nil && ctx.Err() != nil { + return ctx.Err() } - return result, nil + return err } type editingContextCoreRows struct { @@ -123,7 +134,7 @@ func (r *editingContextCoreRows) GetInEdgesByNodeIDs(ids []string) map[string][] // requestLocalEditingContext borrows only the request's already captured // in-memory file layer. It never refetches the selected store, parses bytes, // or turns unavailable cross-file relations into an apparently complete set. -func requestLocalEditingContext(ctx context.Context, path string) *graph.FileEditingContextResult { +func requestLocalFileLayer(ctx context.Context, path string) *graph.OverlayLayer { var selected *graph.OverlayLayer for _, candidate := range requestProjectionLayers(ctx) { if layer, ok := candidate.(*graph.OverlayLayer); ok && layer.HasFile(path) { @@ -135,6 +146,11 @@ func requestLocalEditingContext(ctx context.Context, path string) *graph.FileEdi selected = layer } } + return selected +} + +func requestLocalEditingContext(ctx context.Context, path string) *graph.FileEditingContextResult { + selected := requestLocalFileLayer(ctx, path) result := &graph.FileEditingContextResult{} if selected == nil { return result @@ -155,3 +171,95 @@ func requestLocalEditingContext(ctx context.Context, path string) *graph.FileEdi } return result } + +// fileSummaryContext preserves the full legacy symbols/subgraph/count shape +// while all physical rows, endpoint hydration and importer facts share one +// checked transaction and budget. Counts never use an errorless scalar API. +func fileSummaryContext(ctx context.Context, reader graph.Reader, path string, countsOnly, includeDependents bool) (*query.SubGraph, []string, error) { + var result *query.SubGraph + var dependents []string + err := withFileStoreRows(ctx, reader, func(rows graph.FileEditingContextRows) error { + result, dependents = fileSummaryRows(rows, path, countsOnly, includeDependents) + return nil + }) + if err != nil { + return nil, nil, err + } + return result, dependents, nil +} + +func fileSummaryRows(rows graph.FileEditingContextRows, path string, countsOnly, includeDependents bool) (*query.SubGraph, []string) { + nodes := rows.GetFileNodes(path) + ids := make([]string, 0, len(nodes)) + for _, node := range nodes { + if node != nil && node.ID != "" { + ids = append(ids, node.ID) + } + } + outgoing := rows.GetOutEdgesByNodeIDs(ids) + incoming := rows.GetInEdgesByNodeIDs(ids) + type edgeKey struct { + from, to string + kind graph.EdgeKind + } + seen := make(map[edgeKey]bool) + var edges []*graph.Edge + for _, id := range ids { + for _, batch := range [][]*graph.Edge{outgoing[id], incoming[id]} { + for _, edge := range batch { + if edge == nil { + continue + } + key := edgeKey{edge.From, edge.To, edge.Kind} + if !seen[key] { + seen[key] = true + edges = append(edges, edge) + } + } + } + } + result := &query.SubGraph{Nodes: nodes, Edges: edges, TotalNodes: len(nodes), TotalEdges: len(edges)} + if countsOnly { + result.Edges = nil + } + var dependents []string + if includeDependents { + fileIncoming, loaded := incoming[path] + if !loaded { + fileIncoming = rows.GetInEdgesByNodeIDs([]string{path})[path] + } + var sources []string + for _, edge := range fileIncoming { + if edge != nil && edge.Kind == graph.EdgeImports { + sources = append(sources, edge.From) + } + } + sourceNodes := rows.GetNodesByIDs(sources) + known := make(map[string]bool) + for _, id := range sources { + file := id + if node := sourceNodes[id]; node != nil { + if node.Kind == graph.KindFile { + file = node.ID + } else if node.FilePath != "" { + file = node.FilePath + } + } + if file != "" && file != path && !known[file] { + known[file] = true + dependents = append(dependents, file) + } + } + sort.Strings(dependents) + } + return result, dependents +} + +func requestLocalFileSummary(ctx context.Context, path string, countsOnly bool) *query.SubGraph { + layer := requestLocalFileLayer(ctx, path) + if layer == nil { + return &query.SubGraph{} + } + result, _ := fileSummaryRows(graph.NewOverlaidViewWithLayer(nil, layer), path, countsOnly, false) + return result +} diff --git a/internal/mcp/store_budget_summary_handler_test.go b/internal/mcp/store_budget_summary_handler_test.go new file mode 100644 index 000000000..6f00ebc9d --- /dev/null +++ b/internal/mcp/store_budget_summary_handler_test.go @@ -0,0 +1,118 @@ +package mcp + +import ( + "context" + "encoding/json" + "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/query" +) + +func (s *handlerBudgetStore) GetInEdges(id string) []*graph.Edge { + s.legacyReads++ + return s.Store.GetInEdges(id) +} + +func (s *handlerBudgetStore) GetNode(id string) *graph.Node { + s.legacyReads++ + return s.Store.GetNode(id) +} + +func (s *handlerBudgetStore) GetFileSubGraph(path string) ([]*graph.Node, []*graph.Edge) { + s.legacyReads++ + return s.Store.(graph.FileSubGraphReader).GetFileSubGraph(path) +} + +func (s *handlerBudgetStore) GetFileSubGraphCounts(path string) ([]*graph.Node, int) { + s.legacyReads++ + return s.Store.(graph.FileSubGraphCountReader).GetFileSubGraphCounts(path) +} + +func TestUneditedSummaryStoreDeadlineReturnsPendingWithoutLegacyReads(t *testing.T) { + for _, format := range []string{"json", "gcx"} { + t.Run(format, func(t *testing.T) { + s, _ := setupTestServer(t) + s.indexer = nil // Unedited selected-store path, no projection to rescue it. + ctx := withRequestProjectionFacts(t.Context()) + store := &handlerBudgetStore{Store: s.graph} + s.graph = store + s.engine = query.NewEngine(store) + req := mcplib.CallToolRequest{} + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "main.go", "format": format} + result, err := s.handleGetFileSummary(ctx, req) + require.NoError(t, err) + require.False(t, result.IsError, "%s", resultText(result)) + require.True(t, store.budgetRead, "actual summary symbols/subgraph/counts must use the selected 30ms store scope") + require.Zero(t, store.legacyReads, "summary timeout cannot fall back to errorless symbols/subgraph/count APIs") + fields := map[string]any{} + if format == "json" { + fields = extractTextResult(t, result) + require.Empty(t, fields["nodes"], "no stale unedited rows may escape a refused scope") + } + s.freshnessFor(ctx, "relations", nil, []string{"main.go"}).apply(fields) + require.Equal(t, "pending", fields["graph_freshness"]) + require.Equal(t, "store_busy", fields["reason"]) + require.Equal(t, false, fields["complete"]) + }) + } +} + +type handlerHealthyBudgetStore struct{ handlerBudgetStore } + +func (s *handlerHealthyBudgetStore) WithFileEditingContextReader(ctx context.Context, fn func(context.Context, graph.FileEditingContextRows) error) error { + deadline, ok := ctx.Deadline() + s.budgetRead = ok && time.Until(deadline) <= 30*time.Millisecond + return s.Store.(graph.FileEditingContextReadScope).WithFileEditingContextReader(ctx, fn) +} + +func TestUneditedSummaryCheckedScopePreservesFullRowsCountsAndETag(t *testing.T) { + for _, format := range []string{"json", "gcx"} { + t.Run(format, func(t *testing.T) { + s, _ := setupTestServer(t) + s.indexer = nil + s.graph.AddNode(&graph.Node{ID: "other.go", Kind: graph.KindFile, FilePath: "other.go", Meta: map[string]any{"full": "importer metadata"}}) + s.graph.AddEdge(&graph.Edge{From: "other.go", To: "main.go", Kind: graph.EdgeImports, FilePath: "other.go", Meta: map[string]any{"full": "importer edge"}}) + want := s.engine.GetFileSymbols("main.go") + if format == "gcx" { + want = s.engine.GetFileSymbolsCounts("main.go") + } + want = stripNonDefinitionNodes(want) + wantETag := etagSubGraph(want) + store := &handlerHealthyBudgetStore{handlerBudgetStore{Store: s.graph}} + s.graph = store + s.engine = query.NewEngine(store) + req := mcplib.CallToolRequest{} + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "main.go", "format": format} + result, err := s.handleGetFileSummary(withRequestProjectionFacts(t.Context()), req) + require.NoError(t, err) + require.False(t, result.IsError, "%s", resultText(result)) + require.True(t, store.budgetRead) + require.Zero(t, store.legacyReads) + if format == "gcx" { + encoded, err := encodeFileSummary(want, wantETag) + require.NoError(t, err) + require.Equal(t, string(encoded), resultText(result)) + } else { + fields := extractTextResult(t, result) + require.Equal(t, []any{"other.go"}, fields["dependents"], "importer hydration must stay inside the same checked store scope") + wantNodes, err := json.Marshal(want.Nodes) + require.NoError(t, err) + gotNodes, err := json.Marshal(fields["nodes"]) + require.NoError(t, err) + require.JSONEq(t, string(wantNodes), string(gotNodes), "full node metadata must survive checked hydration") + wantEdges, err := json.Marshal(want.Edges) + require.NoError(t, err) + gotEdges, err := json.Marshal(fields["edges"]) + require.NoError(t, err) + require.JSONEq(t, string(wantEdges), string(gotEdges)) + require.Equal(t, wantETag, fields["etag"]) + } + }) + } +} diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index b4f089a9b..adb88988b 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -3,6 +3,7 @@ package mcp import ( "context" "encoding/json" + "errors" "fmt" "path/filepath" "sort" @@ -2767,22 +2768,19 @@ func (s *Server) handleGetFileSummary(ctx context.Context, req mcp.CallToolReque return mcp.NewToolResultError(err.Error()), nil } - // gcx is the high-volume agent format and only emits total_edges - // in its meta header — never per-edge rows. Route gcx-only calls - // through the count-only path so the disk backends skip - // materialising every adjacent edge across cgo (a 4 000-row - // round-trip on a 500-symbol file becomes two scalar aggregates). - // compact + json paths still take the full SubGraph because - // compact summarises edges per confidence label and json ships - // every edge in the body. + // GCX emits an adjacent-edge count; other formats retain the full rows. + // Both shapes must come from the same checked, deadline-bound snapshot, + // never an errorless count/subgraph fallback outside the store scope. gcxOnly := s.isGCX(ctx, req) && !isCompact(req) - var sg *query.SubGraph - if gcxOnly { - sg = s.engineFor(ctx).GetFileSymbolsCounts(fp) - } else { - sg = s.engineFor(ctx).GetFileSymbols(fp) - } - if len(sg.Nodes) == 0 { + sg, dependents, storeErr := fileSummaryContext(ctx, s.readerFor(ctx), fp, gcxOnly, !gcxOnly && !isCompact(req)) + storeBusy := errors.Is(storeErr, context.DeadlineExceeded) && ctx.Err() == nil + if storeBusy { + ctx = markRequestStoreBusy(ctx, fp) + sg = requestLocalFileSummary(ctx, fp, gcxOnly) + } else if storeErr != nil { + return mcp.NewToolResultError("get_file_summary: " + storeErr.Error()), nil + } + if len(sg.Nodes) == 0 && !storeBusy { // Not in the graph. Say whether the walk will EVER hold it — an // exclude rule, a language with no extractor, or the size cap all mean // no graph tool can answer for this path, so the guidance names @@ -2796,7 +2794,7 @@ func (s *Server) handleGetFileSummary(ctx context.Context, req mcp.CallToolReque return mcp.NewToolResultError(filterErr.Error()), nil } sg = filterSubGraph(sg, allowed) - if len(sg.Nodes) == 0 { + if len(sg.Nodes) == 0 && !storeBusy { // Filtered out by the repo/ref filter: indexed, just not in scope. // OutOfScope rather than a bare Indexed — the same filter applies to // the locators, so they have no rows for it either and the guidance @@ -2812,7 +2810,7 @@ func (s *Server) handleGetFileSummary(ctx context.Context, req mcp.CallToolReque // here so every output format (compact, gcx, json, toon) sees the // same shape. sg = stripNonDefinitionNodes(sg) - if len(sg.Nodes) == 0 { + if len(sg.Nodes) == 0 && !storeBusy { // Only file/import nodes survived: the file IS indexed, it just defines // no symbols (a package-doc-only doc.go, a constants file, a shell or // SQL file with no function definitions). The locators still have rows @@ -2827,7 +2825,7 @@ func (s *Server) handleGetFileSummary(ctx context.Context, req mcp.CallToolReque // every call was the dominant cost on large files (~2 ms / call on // a 500-symbol file). etag := etagSubGraph(sg) - if ifNoneMatch := req.GetString("if_none_match", ""); ifNoneMatch != "" && ifNoneMatch == etag { + if ifNoneMatch := req.GetString("if_none_match", ""); !storeBusy && ifNoneMatch != "" && ifNoneMatch == etag { return notModifiedResult(etag), nil } @@ -2864,7 +2862,10 @@ func (s *Server) handleGetFileSummary(ctx context.Context, req mcp.CallToolReque "truncated": sg.Truncated, "etag": etag, } - s.attachFileDependents(ctx, result, fp) + if len(dependents) > 0 { + result["dependents"] = dependents + result["dependents_header"] = fileDependentsNote(dependents) + } if payload, merr := json.Marshal(result); merr == nil { s.recordFileBaselineSavings(ctx, "get_file_summary", fp, summaryLang, string(payload)) } From 09469967ee7c61a38ae141c9159fb53532551bd0 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:25:58 +0100 Subject: [PATCH 26/61] fix(mcp): admit newly published read projections without awaiting graph work --- internal/mcp/freshness_rider.go | 17 +++++++++++ internal/mcp/node_source.go | 2 +- internal/mcp/projected_reads.go | 18 +++++++++--- internal/mcp/read_projection_admission.go | 29 +++++++++++++++++++ .../mcp/read_projection_admission_test.go | 27 +++++++++++++++++ internal/mcp/relation_freshness.go | 15 +++++----- 6 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 internal/mcp/read_projection_admission.go diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index fde874251..e41f274db 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -630,6 +630,19 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st put(file, "pending", 0) } for file, reason := range deferredFiles { + if reason == "watcher_unavailable" { + if len(ids)+len(files) > 0 && !inScope(file) { + continue + } + if len(ids)+len(files) == 0 { + if prefix := baseNarrowedRepoPrefix(requestViewFromContext(ctx)); prefix != "" && !strings.HasPrefix(s.repoRelative(file), prefix+"/") { + continue + } + } + label.backgroundMissing = true + put(file, "pending", 0) + continue + } put(file, "pending", 0) if _, included := states[s.repoRelative(file)]; included { label.fileReason = reason @@ -1103,6 +1116,10 @@ func (s *Server) awaitWatchedFreshness(ctx context.Context, name string, req mcp } func (f relationFreshness) apply(result map[string]any) { + if f.backgroundMissing { + result["background_admission"] = "unavailable" + result["background_state"] = "pending" + } if f.projected { result["file_view"] = "projected" result["readback_exact"] = true diff --git a/internal/mcp/node_source.go b/internal/mcp/node_source.go index 92a375d88..60653a35f 100644 --- a/internal/mcp/node_source.go +++ b/internal/mcp/node_source.go @@ -137,7 +137,7 @@ func (s *Server) readNodeSource(ctx context.Context, node *graph.Node, absPath s if owner == nil { return nodeSource{}, fmt.Errorf("no indexer owns %s", node.FilePath) } - if requestDeferredProjectionFiles(ctx)[node.FilePath] != "" { + if reason := requestDeferredProjectionFiles(ctx)[node.FilePath]; reason == "storm" || reason == "external_write" { return nodeSource{}, errProjectedSourceDeferred } root, rel, key, err := selectedProjectionReadPath(ctx, owner.RootPath(), owner.RepoPrefix(), absPath) diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index 54e882a99..548b7c6e9 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -90,7 +90,7 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C } entry, err := s.projectCurrentTrackedFile(buildCtx, ctx, abs, graphPath, rel) if err != nil { - if projectionBudgetUnavailable(ctx, err) { + if projectionBudgetUnavailable(ctx, err) || ctx.Err() == nil && (errors.Is(err, errProjectionExternalWrite) || errors.Is(err, projectionSuperseded)) { markRequestProjectionDeferred(ctx, graphPath, "external_write") continue } @@ -124,6 +124,10 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C } ctx, err = retainRequestProjection(ctx, entry) if err != nil { + if ctx.Err() == nil && errors.Is(err, projectionSuperseded) { + markRequestProjectionDeferred(ctx, graphPath, "external_write") + continue + } return ctx, err } layers = append(layers, layer) @@ -169,6 +173,7 @@ func selectedProjectionReadPath(ctx context.Context, root, prefix, abs string) ( func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, abs, graphPath, rel string) (*projectionEntry, error) { idx, _ := s.indexerForRel(graphPath) + tracked := idx.IsTracked(rel) root, readPath, key, err := selectedProjectionReadPath(requestCtx, idx.RootPath(), idx.RepoPrefix(), abs) if err != nil { return nil, err @@ -179,7 +184,10 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, return nil, err } if before.info == nil { - if !idx.IsTracked(rel) && expected == nil { + if expected != nil && expected.tombstone && liveProjectionForAcknowledgement(expected) { + return expected, nil + } + if !tracked && expected == nil { return nil, nil } entry := newProjectionTombstone(abs, graphPath, idx.RepoPrefix(), 0) @@ -190,6 +198,7 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, if err := pendingFileProjections.publishExpected(entry, expected); err != nil { return nil, err } + s.admitReadBuiltProjection(requestCtx, root, idx, tracked, entry) return entry, nil } // Reuse the confined, bounded, nonblocking regular-file reader. A FIFO, @@ -206,7 +215,7 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, // The existing buffer admission applies the owning indexer's exclusions, // selected checkout's ignore files, language and content gates to these // already-confined bytes. A new file need not have a watcher inventory row. - if !idx.IsTracked(rel) && !idx.AdmitSourceSearchBuffer(buildCtx, root, abs, raw, nil) { + if !tracked && !idx.AdmitSourceSearchBuffer(buildCtx, root, abs, raw, nil) { return nil, errors.New("file is not admitted source") } after, err := statDiskStamp(abs) @@ -237,7 +246,7 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, if err != nil { return nil, err } - if !idx.IsTracked(rel) && !projection.ParseChecked { + if !tracked && !projection.ParseChecked { return nil, errors.New("untracked source parse health is unavailable") } entry := newProjectionEntry(abs, idx.RepoPrefix(), projection, raw, 0, after) @@ -255,6 +264,7 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, if err := pendingFileProjections.publishExpected(entry, expected); err != nil { return nil, err } + s.admitReadBuiltProjection(requestCtx, root, idx, tracked, entry) return entry, nil } diff --git a/internal/mcp/read_projection_admission.go b/internal/mcp/read_projection_admission.go new file mode 100644 index 000000000..dce22ee91 --- /dev/null +++ b/internal/mcp/read_projection_admission.go @@ -0,0 +1,29 @@ +package mcp + +import ( + "context" + + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathguard" +) + +// Read-built publication can request eventual canonical catch-up, without +// retaining a ticket or borrowing physical-write/ACK/completion authority. +func (s *Server) admitReadBuiltProjection(ctx context.Context, root string, idx *indexer.Indexer, tracked bool, entry *projectionEntry) { + if !tracked || pathguard.ResolveRoot(root) != pathguard.ResolveRoot(idx.RootPath()) { + return + } + if watcher, ok := s.currentWatcher().(interface { + EnqueueToolMutation(context.Context, string) (*indexer.MutationTicket, error) + }); ok { + ticket, err := watcher.EnqueueToolMutation(ctx, entry.absPath) + if err == nil && ticket != nil { + return + } + } + // This is only a request observation. A later cache hit cannot infer an + // accepted admission or completion from the absence of this optional field. + if _, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts); ok { + markRequestProjectionDeferred(ctx, entry.graphPath, "watcher_unavailable") + } +} diff --git a/internal/mcp/read_projection_admission_test.go b/internal/mcp/read_projection_admission_test.go index 6a568bbd2..fe84b8d04 100644 --- a/internal/mcp/read_projection_admission_test.go +++ b/internal/mcp/read_projection_admission_test.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "errors" "os" "path/filepath" "sync/atomic" @@ -74,3 +75,29 @@ func TestReadProjectionDoesNotAdmitWithoutPublicationOrAcrossCheckouts(t *testin }) } } + +func TestReadProjectionAdmissionFailureKeepsExactFileAndPendingBackground(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupTestServer(t) + var admitted atomic.Int64 + s.SetWatcher(mutationTestWatcher{scheduled: &admitted, scheduleErr: errors.New("admission refused")}) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(before, []byte("\nfunc StillReadable() {}\n")...), 0600)) + req := mcplib.CallToolRequest{} + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "main.go", "format": "json"} + result, err := s.wrapToolHandler(s.handleGetFileSummary)(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "refused background admission cannot erase exact available file rows: %s", resultText(result)) + require.EqualValues(t, 1, admitted.Load()) + fields := extractTextResult(t, result) + freshness := fields["freshness"].(map[string]any) + require.Equal(t, true, freshness["readback_exact"]) + require.Equal(t, "projected", freshness["file_view"]) + require.Equal(t, "pending", freshness["graph_freshness"]) + require.Equal(t, "unavailable", freshness["background_admission"]) + require.Equal(t, "pending", freshness["background_state"]) + require.Nil(t, s.graph.GetNode("main.go::StillReadable")) +} diff --git a/internal/mcp/relation_freshness.go b/internal/mcp/relation_freshness.go index f06fd3584..8cb2790aa 100644 --- a/internal/mcp/relation_freshness.go +++ b/internal/mcp/relation_freshness.go @@ -27,13 +27,14 @@ 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 { - projected bool // current request served an exact file-local projection - storeBusy bool // selected store sections were unavailable - fileReason string // an observed reason the request deferred a file projection - 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 + backgroundMissing bool // this request could not obtain background admission + projected bool // current request served an exact file-local projection + storeBusy bool // selected store sections were unavailable + fileReason string // an observed reason the request deferred a file projection + 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 } // relationWaitDeadline is when a change tool stops waiting for its relation From adbd37118f658f63a59a9a2a63003af9f3ff0e43 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:25:58 +0100 Subject: [PATCH 27/61] fix(mcp): fence deferred compression and whole-call source retries --- internal/mcp/overlay.go | 4 +- internal/mcp/pending_projection.go | 20 ++ .../projected_read_frontend_contract_test.go | 2 +- .../projected_read_review_regression_test.go | 200 ++++++++++++++++++ internal/mcp/projected_source_retry.go | 49 ++++- internal/mcp/tools_fileops.go | 11 +- 6 files changed, 273 insertions(+), 13 deletions(-) create mode 100644 internal/mcp/projected_read_review_regression_test.go diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index f35b23217..90d401c8c 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -95,7 +95,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // of this function) so it bounds the whole middleware chain, not just the // leaf handler — the overlay build, the freshness sweep, and the response // capture below all touch the graph and can block on the same locks. - return s.boundToolHandler(s.retryFreshSymbolSearch(s.retryProjectedSymbolSource(func(ctx context.Context, req mcp.CallToolRequest) (res *mcp.CallToolResult, retErr error) { + return s.boundToolHandler(s.withProjectedSourceReadBudget(s.retryFreshSymbolSearch(s.retryProjectedSymbolSource(func(ctx context.Context, req mcp.CallToolRequest) (res *mcp.CallToolResult, retErr error) { s.noteAcknowledgementMiddleware(ctx) // The instant the call entered the middleware: the origin a mutation's // or a fresh request's publication phases are measured from. @@ -475,7 +475,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay } } return res, hErr - }))) + })))) } // refuseWithdrawnExactness is require_exact's post-handler half: it refuses an diff --git a/internal/mcp/pending_projection.go b/internal/mcp/pending_projection.go index 1415bbf83..ee621edf5 100644 --- a/internal/mcp/pending_projection.go +++ b/internal/mcp/pending_projection.go @@ -335,6 +335,11 @@ func (p *pendingProjections) dropAll(entries []*projectionEntry) int { } p.mu.Lock() defer p.mu.Unlock() + return p.dropAllLocked(entries) +} + +// dropAllLocked requires p.mu; callers already checked pointer ownership. +func (p *pendingProjections) dropAllLocked(entries []*projectionEntry) int { cur := p.snap.Load() next := &projectionSnapshot{bytes: cur.bytes} dropped := 0 @@ -355,6 +360,21 @@ func (p *pendingProjections) dropAll(entries []*projectionEntry) int { return dropped } +// Invalidation and expected-pointer publication share one linearization +// boundary. An already-published newer owner is never marked or removed. +func (p *pendingProjections) invalidateCurrent(entry *projectionEntry) { + if entry == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + if p.snap.Load().entries[entry.key] != entry { + return + } + entry.suspect.Store(true) + p.dropAllLocked([]*projectionEntry{entry}) +} + // projectionStore is what retirement reads: a file's content identity, then // its nodes. type projectionStore interface { diff --git a/internal/mcp/projected_read_frontend_contract_test.go b/internal/mcp/projected_read_frontend_contract_test.go index 3166fd287..50ec6c235 100644 --- a/internal/mcp/projected_read_frontend_contract_test.go +++ b/internal/mcp/projected_read_frontend_contract_test.go @@ -165,7 +165,7 @@ func TestSymbolSourceRetryIsBoundedAndCancellationDoesNotReplay(t *testing.T) { req.Params.Arguments = map[string]any{"id": "sample.go::Probe", "context_lines": 0, "format": "json"} // Audit the retry controller over the actual source handler synchronously; // the public middleware race is covered by the positive test above. - result, err := s.retryProjectedSymbolSource(actual)(ctx, req) + result, err := s.withProjectedSourceReadBudget(s.retryProjectedSymbolSource(actual))(ctx, req) if cancelFirst { require.True(t, errors.Is(err, context.Canceled) || result != nil && result.IsError) require.EqualValues(t, 1, calls.Load()) diff --git a/internal/mcp/projected_read_review_regression_test.go b/internal/mcp/projected_read_review_regression_test.go new file mode 100644 index 000000000..3a7dab00c --- /dev/null +++ b/internal/mcp/projected_read_review_regression_test.go @@ -0,0 +1,200 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "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" +) + +func TestDeferredReadFileCompressionNeverUsesOldKeepRanges(t *testing.T) { + selectedProjectionRegistry(t) + original := "package sample\nfunc Keep() {\n println(1)\n println(2)\n println(3)\n}\nfunc Other() {\n println(4)\n println(5)\n println(6)\n}\n" + s, root := setupMoveInlineRepo(t, map[string]string{"sample.go": original}) + path := filepath.Join(root, "sample.go") + current := "package sample\nfunc Intruder() {\n println(7)\n println(8)\n println(9)\n}\n" + strings.TrimPrefix(original, "package sample\n") + require.NoError(t, os.WriteFile(path, []byte(current), 0600)) + s.SetWatcher(projectedStormWatcher{path: path}) + req := makeReq("read_file", map[string]any{"path": "sample.go", "compress_bodies": true, "keep": "sample.go::Keep", "format": "json"}) + result, err := s.wrapToolHandler(s.handleReadFile)(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "%s", resultText(result)) + fields := extractTextResult(t, result) + require.Equal(t, current, fields["content"], "unavailable current ranges cannot authorize graph-dependent elision") + require.Equal(t, "pending", fields["compression_state"]) + require.Empty(t, fields["kept_symbols"]) + require.Empty(t, pendingFileProjections.snap.Load().entries) + require.Nil(t, s.graph.GetNode("sample.go::Intruder")) + freshness := fields["freshness"].(map[string]any) + require.Equal(t, "storm", freshness["file_reason"]) + require.NotEqual(t, true, freshness["readback_exact"]) +} + +func TestProjectedSourceInvalidationCannotPoisonPublisherHoldingLock(t *testing.T) { + selectedProjectionRegistry(t) + p := pendingFileProjections + path := filepath.Join(t.TempDir(), "sample.go") + rawA := []byte("package sample\nfunc A() {}\n") + rawB := []byte("package sample\nfunc B() {}\n") + require.NoError(t, os.WriteFile(path, rawA, 0600)) + stampA, err := statDiskStamp(path) + require.NoError(t, err) + a := syntheticProjectionEntry(path, "sample.go", "a", 0, stampA, rawA) + require.NoError(t, p.publishExpected(a, nil)) + require.NoError(t, os.WriteFile(path, rawB, 0600)) + stampB, err := statDiskStamp(path) + require.NoError(t, err) + b := syntheticProjectionEntry(path, "sample.go", "b", 0, stampB, rawB) + // Model the real publisher after its expected-old-pointer check, before + // publishLocked. The invalidator must serialize at that same boundary. + p.mu.Lock() + var unlock sync.Once + release := func() { unlock.Do(p.mu.Unlock) } + done := make(chan struct{}) + id := make(chan string, 1) + go func() { + defer close(done) + stack := make([]byte, 256) + n := runtime.Stack(stack, false) + id <- strings.Fields(string(stack[:n]))[1] + _ = invalidateProjectedSource(a) + }() + t.Cleanup(func() { release(); <-done }) + goroutine := <-id + require.Eventually(t, func() bool { + stack := make([]byte, 1<<20) + n := runtime.Stack(stack, true) + for _, block := range strings.Split(string(stack[:n]), "\n\n") { + if strings.HasPrefix(block, "goroutine "+goroutine+" ") { + return strings.Contains(block, "invalidateProjectedSource") && strings.Contains(block, "Mutex") + } + } + return false + }, time.Second, time.Millisecond, "actual invalidation must be waiting at the held publication lock") + markedBeforePublish := a.suspect.Load() + err = p.publishLocked(b) + release() + <-done + require.NoError(t, err) + require.False(t, markedBeforePublish, "invalidation cannot mark the old entry outside publication synchronization") + require.Same(t, b, p.snap.Load().entries[b.key]) + require.False(t, b.suspect.Load(), "a stale invalidator must not poison the newer publisher") + require.True(t, liveProjectionForAcknowledgement(b)) + require.False(t, liveProjectionForAcknowledgement(a)) +} + +func TestSymbolSourceMixedRetryPathsShareTwoActualAttempts(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupMoveInlineRepo(t, map[string]string{"sample.go": "package sample\nfunc Probe() int { return 0 }\n"}) + s.SetWatcher(mutationTestWatcher{}) + path := filepath.Join(root, "sample.go") + require.NoError(t, os.WriteFile(path, []byte("package sample\nfunc Probe() int { return 1 }\n"), 0600)) + calls := 0 + var deadlines []time.Time + leaf := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + calls++ + deadlines = append(deadlines, ctx.Value(projectedSourceDeadlineKey{}).(time.Time)) + var err error + ctx, err = s.ensureProjected(ctx, []string{"sample.go"}) + if err != nil { + return nil, err + } + if calls == 1 { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if err := os.WriteFile(path, []byte("package sample\nfunc Probe() int { return 2 }\n"), 0600); err != nil { + return nil, err + } + if err := os.Chtimes(path, info.ModTime(), info.ModTime()); err != nil { + return nil, err + } + } + result, err := s.handleGetSymbolSource(ctx, req) + if calls == 2 && err == nil && result != nil && !result.IsError { + // The same late-withdrawal seam exercised by fresh_symbol_retry_test: + // a completed source attempt must consume the outer replay allowance too. + ctx.Value(freshSymbolAttemptKey{}).(*freshSymbolAttempt).withdrawn = true + } + return result, err + } + args := map[string]any{"id": "sample.go::Probe", "context_lines": 0, "format": "json", requireExactArgName: true, requireFreshArgName: true, waitDeadlineArgName: time.Now().Add(time.Second).Format(time.RFC3339Nano)} + result, err := s.wrapToolHandler(leaf)(t.Context(), makeReq("get_symbol_source", args)) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.IsError, "%s", resultText(result)) + require.Equal(t, 2, calls, "typed mismatch plus late withdrawal cannot permit a third actual handler invocation") + for _, deadline := range deadlines { + require.Equal(t, deadlines[0], deadline, "outer replay cannot renew the original projection budget") + } + require.NotContains(t, resultText(result), "readback_exact") + require.Equal(t, "sample.go::Probe", args["id"]) +} + +// Reuse the real Server.SetWatcher callback seam; the actual native write is +// deliberately same-length/same-mtime, so an event must invalidate the ACK view. +type externalAfterToolWatcher struct { + mutationTestWatcher + sourceEvent func(string) +} + +func (w *externalAfterToolWatcher) OnSourceEvent(callback func(string)) { w.sourceEvent = callback } + +func TestExternalWriteAfterToolEdit(t *testing.T) { + selectedProjectionRegistry(t) + s, root := ackModeServer(t, "local") + pending := make(chan indexer.MutationResult) + defer close(pending) + watcher := &externalAfterToolWatcher{mutationTestWatcher: mutationTestWatcher{done: pending}} + s.SetWatcher(watcher) + path := filepath.Join(root, "main.go") + replacement := "func NativeAfterTool() int { return 1 }" + req := makeReq("edit_file", map[string]any{"path": path, "old_string": "func helper() {}", "new_string": replacement, "format": "json"}) + result, err := s.runAcknowledgedEdit(t.Context(), req, s.handleEditFile) + require.NoError(t, err) + require.False(t, result.IsError, "%s", resultText(result)) + ack := extractTextResult(t, result) + require.Equal(t, true, ack["committed"]) + require.Equal(t, "local", ack["ack_mode"]) + old := pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)] + require.NotNil(t, old) + require.Positive(t, old.commitSeq) + raw, err := os.ReadFile(path) + require.NoError(t, err) + info, err := os.Stat(path) + require.NoError(t, err) + current := strings.Replace(string(raw), replacement, "func NativeAfterTool() int { return 2 }", 1) + require.NotEqual(t, string(raw), current) + require.NoError(t, os.WriteFile(path, []byte(current), 0600)) + require.NoError(t, os.Chtimes(path, info.ModTime(), info.ModTime())) + require.NotNil(t, watcher.sourceEvent) + watcher.sourceEvent(path) + require.True(t, old.suspect.Load()) + read := makeReq("get_symbol_source", map[string]any{"id": "main.go::NativeAfterTool", "context_lines": 0, "format": "json"}) + result, err = s.wrapToolHandler(s.handleGetSymbolSource)(t.Context(), read) + require.NoError(t, err) + require.False(t, result.IsError, "%s", resultText(result)) + fields := extractTextResult(t, result) + require.Equal(t, "func NativeAfterTool() int { return 2 }", fields["source"]) + fresh := pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)] + require.NotNil(t, fresh) + require.NotSame(t, old, fresh) + require.Zero(t, fresh.commitSeq, "native read-built projection cannot reuse the prior tool's physical receipt") + require.False(t, liveProjectionForAcknowledgement(old)) + require.NotNil(t, s.graph.GetNode("main.go::helper")) + require.Nil(t, s.graph.GetNode("main.go::NativeAfterTool"), "neither exact read nor event queues may publish the held graph") + freshness := fields["freshness"].(map[string]any) + require.Equal(t, true, freshness["readback_exact"]) + require.Equal(t, "projected", freshness["file_view"]) +} diff --git a/internal/mcp/projected_source_retry.go b/internal/mcp/projected_source_retry.go index 65213c46a..70e707e26 100644 --- a/internal/mcp/projected_source_retry.go +++ b/internal/mcp/projected_source_retry.go @@ -3,6 +3,7 @@ package mcp import ( "context" "errors" + "sync/atomic" "time" mcplib "github.com/mark3labs/mcp-go/mcp" @@ -13,24 +14,57 @@ var errProjectedSourceMoved = errors.New("selected source bytes changed during t var errProjectedSourceDeferred = errors.New("source is pending after the projection budget expired") func invalidateProjectedSource(entry *projectionEntry) error { - entry.suspect.Store(true) - pendingFileProjections.drop(entry) + pendingFileProjections.invalidateCurrent(entry) return errProjectedSourceMoved } type projectedSourceDeadlineKey struct{} +type projectedSourceAllowanceKey struct{} +type projectedSourceAllowance struct{ attempts atomic.Int32 } + +func (s *Server) withProjectedSourceReadBudget(next mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + legacy, known := s.legacyToolName(&req) + if !known || legacy != "get_symbol_source" { + return next(ctx, req) + } + if _, exists := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance); !exists { + deadline := time.Now().Add(projectedReadBudget) + if prior, ok := ctx.Value(projectedSourceDeadlineKey{}).(time.Time); ok && prior.Before(deadline) { + deadline = prior + } + ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, deadline) + ctx = context.WithValue(ctx, projectedSourceAllowanceKey{}, &projectedSourceAllowance{}) + } + return next(ctx, req) + } +} + +func (a *projectedSourceAllowance) claim() bool { + for { + before := a.attempts.Load() + if before >= 2 { + return false + } + if a.attempts.CompareAndSwap(before, before+1) { + return true + } + } +} // Only this audited source read retries, once, for a typed byte mismatch. Each // attempt runs the whole middleware with fresh facts/layers and releases its // leases before the next; the first attempt's projection deadline is retained. func (s *Server) retryProjectedSymbolSource(attempt mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - legacy, known := s.legacyToolName(&req) - if !known || legacy != "get_symbol_source" { + allowance, ok := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance) + if !ok { return attempt(ctx, req) } - ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, time.Now().Add(projectedReadBudget)) - for ordinal := 0; ordinal < 2; ordinal++ { + for { + if !allowance.claim() { + return mcplib.NewToolResultError(errProjectedSourceMoved.Error()), nil + } copy := req copy.Params.Arguments = cloneFreshSymbolArguments(req.GetArguments()) result, err := runProjectedSourceAttempt(ctx, copy, attempt) @@ -41,11 +75,10 @@ func (s *Server) retryProjectedSymbolSource(attempt mcpserver.ToolHandlerFunc) m return nil, ctx.Err() } // A caller-owned selection cannot be rebound by this nested invocation. - if ordinal == 1 || ctx.Value(requestProjectionFactsKey{}) != nil { + if ctx.Value(requestProjectionFactsKey{}) != nil { return mcplib.NewToolResultError(errProjectedSourceMoved.Error()), nil } } - panic("unreachable source retry") } } diff --git a/internal/mcp/tools_fileops.go b/internal/mcp/tools_fileops.go index a4f6a98df..15c9995a4 100644 --- a/internal/mcp/tools_fileops.go +++ b/internal/mcp/tools_fileops.go @@ -1702,6 +1702,7 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* isBinary := looksBinary(content) bodiesElided := false + compressionPending := false var keptSymbols []string var language string if sourceRequestView(ctx) != nil { @@ -1717,10 +1718,12 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* return mcp.NewToolResultError(err.Error()), nil } if !servedFromOverlay { + compressionPending = true if facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts); ok { facts.mu.RLock() entry := facts.entries[pathkey.MutationKey(absPath)] facts.mu.RUnlock() + compressionPending = entry == nil if entry != nil && (!liveProjectionForAcknowledgement(entry) || entry.rawHash != blake3.Sum256(diskContent)) { return mcp.NewToolResultError(errProjectedSourceMoved.Error()), nil } @@ -1730,11 +1733,11 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* // Tool-call observer: credit the recent search for the symbols in // the file the agent is reading. var sg *query.SubGraph - if sourceRequestView(ctx) == nil { + if sourceRequestView(ctx) == nil && !compressionPending { afterFreshSymbolAcceptance(ctx, func() { s.creditFileConsumption(ctx, relPath) }) sg = s.engineFor(ctx).GetFileSymbols(relPath) } - if req.GetBool("compress_bodies", false) && language != "" && elide.IsSupported(language) { + if !compressionPending && req.GetBool("compress_bodies", false) && language != "" && elide.IsSupported(language) { var symbols []*graph.Node if sg != nil { symbols = sg.Nodes @@ -1822,6 +1825,10 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* "original_bytes": originalBytes, "content": string(content), } + if compressionPending { + result["compression_state"] = "pending" + result["compression_reason"] = "source_projection_unavailable" + } if sourceRequestView(ctx) != nil { source := "disk" if servedFromOverlay { From cd1c19d965b47430cfe2a0a9831e6587cb212ddf Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:26:47 +0100 Subject: [PATCH 28/61] test(mcp): report actual mixed retry invocation count before refusal check --- internal/mcp/projected_read_review_regression_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mcp/projected_read_review_regression_test.go b/internal/mcp/projected_read_review_regression_test.go index 3a7dab00c..ceac3d217 100644 --- a/internal/mcp/projected_read_review_regression_test.go +++ b/internal/mcp/projected_read_review_regression_test.go @@ -133,8 +133,8 @@ func TestSymbolSourceMixedRetryPathsShareTwoActualAttempts(t *testing.T) { result, err := s.wrapToolHandler(leaf)(t.Context(), makeReq("get_symbol_source", args)) require.NoError(t, err) require.NotNil(t, result) - require.True(t, result.IsError, "%s", resultText(result)) require.Equal(t, 2, calls, "typed mismatch plus late withdrawal cannot permit a third actual handler invocation") + require.True(t, result.IsError, "%s", resultText(result)) for _, deadline := range deadlines { require.Equal(t, deadlines[0], deadline, "outer replay cannot renew the original projection budget") } From 3fb5264219aaf142b82673b9417ae09fa0de2986 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:49:39 +0100 Subject: [PATCH 29/61] fix(mcp): own source replay allowance at the outer retry boundary --- internal/mcp/fresh_symbol_retry.go | 4 ++-- internal/mcp/overlay.go | 4 ++-- internal/mcp/projected_read_review_regression_test.go | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/mcp/fresh_symbol_retry.go b/internal/mcp/fresh_symbol_retry.go index ca6b42001..3e3d1af03 100644 --- a/internal/mcp/fresh_symbol_retry.go +++ b/internal/mcp/fresh_symbol_retry.go @@ -33,7 +33,7 @@ func freshReadReplayAllowed(legacy string) bool { } func (s *Server) retryFreshSymbolSearch(attempt mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { - return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + return s.withProjectedSourceReadBudget(func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { freshness := takeRequestFreshness(&req) legacy, known := s.legacyToolName(&req) cursor, _ := requestRawArg(&req, "cursor") @@ -75,7 +75,7 @@ func (s *Server) retryFreshSymbolSearch(attempt mcpserver.ToolHandlerFunc) mcpse return last, nil } } - } + }) } func cloneFreshSymbolArguments(args map[string]any) map[string]any { diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index 90d401c8c..f35b23217 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -95,7 +95,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // of this function) so it bounds the whole middleware chain, not just the // leaf handler — the overlay build, the freshness sweep, and the response // capture below all touch the graph and can block on the same locks. - return s.boundToolHandler(s.withProjectedSourceReadBudget(s.retryFreshSymbolSearch(s.retryProjectedSymbolSource(func(ctx context.Context, req mcp.CallToolRequest) (res *mcp.CallToolResult, retErr error) { + return s.boundToolHandler(s.retryFreshSymbolSearch(s.retryProjectedSymbolSource(func(ctx context.Context, req mcp.CallToolRequest) (res *mcp.CallToolResult, retErr error) { s.noteAcknowledgementMiddleware(ctx) // The instant the call entered the middleware: the origin a mutation's // or a fresh request's publication phases are measured from. @@ -475,7 +475,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay } } return res, hErr - })))) + }))) } // refuseWithdrawnExactness is require_exact's post-handler half: it refuses an diff --git a/internal/mcp/projected_read_review_regression_test.go b/internal/mcp/projected_read_review_regression_test.go index ceac3d217..c4a7f6387 100644 --- a/internal/mcp/projected_read_review_regression_test.go +++ b/internal/mcp/projected_read_review_regression_test.go @@ -130,7 +130,10 @@ func TestSymbolSourceMixedRetryPathsShareTwoActualAttempts(t *testing.T) { return result, err } args := map[string]any{"id": "sample.go::Probe", "context_lines": 0, "format": "json", requireExactArgName: true, requireFreshArgName: true, waitDeadlineArgName: time.Now().Add(time.Second).Format(time.RFC3339Nano)} - result, err := s.wrapToolHandler(leaf)(t.Context(), makeReq("get_symbol_source", args)) + // Exercise both real replay controllers over the actual source handler. + // This flat fixture has no working-copy generation coordinator, so the + // public require_fresh route would correctly refuse before any leaf. + result, err := s.retryFreshSymbolSearch(s.retryProjectedSymbolSource(leaf))(t.Context(), makeReq("get_symbol_source", args)) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 2, calls, "typed mismatch plus late withdrawal cannot permit a third actual handler invocation") From bc78ffef2f07f374d903b176fa6eed9568cdb93b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:54:53 +0100 Subject: [PATCH 30/61] test(mcp): use the actual disk verdict in the external-write control --- internal/mcp/projected_read_review_regression_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mcp/projected_read_review_regression_test.go b/internal/mcp/projected_read_review_regression_test.go index c4a7f6387..09dd61030 100644 --- a/internal/mcp/projected_read_review_regression_test.go +++ b/internal/mcp/projected_read_review_regression_test.go @@ -168,7 +168,7 @@ func TestExternalWriteAfterToolEdit(t *testing.T) { require.NoError(t, err) require.False(t, result.IsError, "%s", resultText(result)) ack := extractTextResult(t, result) - require.Equal(t, true, ack["committed"]) + require.Equal(t, "committed", ack["disk_status"]) require.Equal(t, "local", ack["ack_mode"]) old := pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)] require.NotNil(t, old) From 6dca6a492f49f822422c1fbaf63c935ef6334967 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 08:07:51 +0100 Subject: [PATCH 31/61] test(mcp): reproduce observed source-event projection poisoning --- .../mcp/projected_read_event_revision_test.go | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 internal/mcp/projected_read_event_revision_test.go diff --git a/internal/mcp/projected_read_event_revision_test.go b/internal/mcp/projected_read_event_revision_test.go new file mode 100644 index 000000000..79dc852c9 --- /dev/null +++ b/internal/mcp/projected_read_event_revision_test.go @@ -0,0 +1,131 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +func eventProjectionFixture(t *testing.T) (*Server, string, *pendingBudgetExtractor, *projectionEntry) { + t.Helper() + selectedProjectionRegistry(t) + extractor := &pendingBudgetExtractor{base: languages.NewGoExtractor(), entered: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{})} + registry := parser.NewRegistry() + registry.Register(extractor) + s, root := setupTestServerWithRegistry(t, registry) + s.SetWatcher(mutationTestWatcher{}) + path := filepath.Join(root, "main.go") + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(raw, []byte("\nfunc EventA() {}\n")...), 0600)) + entry, err := s.projectCurrentTrackedFile(t.Context(), t.Context(), path, "main.go", "main.go") + require.NoError(t, err) + require.NotNil(t, entry) + return s, path, extractor, entry +} + +func TestObservedSameBytesEventGetsFreshImmutableReadOwner(t *testing.T) { + s, path, _, old := eventProjectionFixture(t) + pendingFileProjections.markSuspect(path) + require.True(t, old.suspect.Load()) + current, err := s.projectCurrentTrackedFile(t.Context(), t.Context(), path, "main.go", "main.go") + require.NoError(t, err) + require.NotSame(t, old, current, "same bytes after observed invalidation need a newly checked owner, not an old physical receipt") + require.Same(t, old.layer, current.layer, "revalidating identical bytes must not parse the file again") + require.Zero(t, current.commitSeq) + require.False(t, current.suspect.Load()) + require.True(t, old.suspect.Load(), "never clear an old reader or historical receipt's invalidation") + require.True(t, liveProjectionForAcknowledgement(current)) + require.False(t, liveProjectionForAcknowledgement(old)) +} + +func TestSourceEventDuringProjectionCannotBeClearedByOldObservation(t *testing.T) { + s, path, extractor, old := eventProjectionFixture(t) + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(raw, []byte("\nfunc EventB() {}\n")...), 0600)) + pendingFileProjections.markSuspect(path) // observed before this read + extractor.hold.Store(true) + ctx, cancel := context.WithTimeout(t.Context(), projectedReadBudget) + defer cancel() + var once sync.Once + release := func() { once.Do(func() { close(extractor.release) }) } + type reply struct { + entry *projectionEntry + err error + } + done := make(chan reply, 1) + go func() { + entry, err := s.projectCurrentTrackedFile(ctx, ctx, path, "main.go", "main.go") + done <- reply{entry, err} + }() + t.Cleanup(func() { release(); <-extractor.finished }) + select { + case <-extractor.entered: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + pendingFileProjections.markSuspect(path) // a second real event during extraction + release() + var result reply + select { + case result = <-done: + case <-ctx.Done(): + result = <-done + t.Fatal(ctx.Err()) + } + require.ErrorIs(t, result.err, projectionSuperseded, "an event after bytes were selected cannot be acknowledged by the older observation") + require.Nil(t, result.entry) + require.Same(t, old, pendingFileProjections.snap.Load().entries[old.key], "refusal must not publish an unusable replacement") + require.True(t, old.suspect.Load()) + // A genuinely new bounded read can observe that event and accept current B. + next, err := s.projectCurrentTrackedFile(t.Context(), t.Context(), path, "main.go", "main.go") + require.NoError(t, err) + require.NotNil(t, next) + require.False(t, next.suspect.Load()) + require.True(t, liveProjectionForAcknowledgement(next)) +} + +func TestDeferredOldProjectionInvalidationCannotPoisonNewerPublisher(t *testing.T) { + s, path, extractor, old := eventProjectionFixture(t) + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(raw, []byte("\nfunc EventB() {}\n")...), 0600)) + extractor.hold.Store(true) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + var once sync.Once + release := func() { once.Do(func() { close(extractor.release) }) } + done := make(chan error, 1) + go func() { _, err := s.projectCurrentTrackedFile(ctx, ctx, path, "main.go", "main.go"); done <- err }() + t.Cleanup(func() { release(); <-extractor.finished }) + select { + case <-extractor.entered: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + newer, err := s.projectCurrentTrackedFile(ctx, ctx, path, "main.go", "main.go") + require.NoError(t, err) + require.NotNil(t, newer) + require.NotSame(t, old, newer) + release() + select { + case err = <-done: + case <-ctx.Done(): + err = <-done + t.Fatal(ctx.Err()) + } + require.ErrorIs(t, err, projectionSuperseded) + require.True(t, old.suspect.Load(), "the late failed read may invalidate only its old pointer") + require.Same(t, newer, pendingFileProjections.snap.Load().entries[newer.key]) + require.False(t, newer.suspect.Load()) + require.True(t, liveProjectionForAcknowledgement(newer)) +} From e363e0caf4205f61f558c27960b168b92bbcc257 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 08:11:01 +0100 Subject: [PATCH 32/61] fix(mcp): revalidate observed source events at read projection publication --- internal/mcp/pending_projection.go | 77 ++++++++++++++++--- .../mcp/projected_read_event_revision_test.go | 29 ++++++- internal/mcp/projected_reads.go | 25 ++++-- 3 files changed, 111 insertions(+), 20 deletions(-) diff --git a/internal/mcp/pending_projection.go b/internal/mcp/pending_projection.go index f5c9acaf1..2f11690c9 100644 --- a/internal/mcp/pending_projection.go +++ b/internal/mcp/pending_projection.go @@ -58,8 +58,8 @@ type projectionSnapshot struct { bytes int64 } -// projectionEntry is one file's projection. Everything but suspect is -// immutable once published. +// projectionEntry is one file's projection. Payload fields are immutable; +// suspect is atomic and sourceEvents is guarded by the owning registry mutex. type projectionEntry struct { key string absPath string @@ -82,7 +82,8 @@ type projectionEntry struct { 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 + suspect atomic.Bool + sourceEvents uint64 // source-event revision, guarded by pendingProjections.mu } // projectionRefusal is why a publish left the live entries as they were. @@ -181,7 +182,7 @@ func newProjectionEntry(absPath, repoPrefix string, projection *indexer.Projecti parseErrors: projection.ParseErrors, parseChecked: projection.ParseChecked, buildTime: projection.BuildTime, - size: int64(len(projection.Nodes))*projectionNodeBytes + int64(len(projection.Edges))*projectionEdgeBytes, + size: int64(len(projection.Nodes))*projectionNodeBytes + int64(len(projection.Edges))*projectionEdgeBytes + 8, } } @@ -213,6 +214,7 @@ func newProjectionTombstone(absPath, graphPath, repoPrefix string, commitSeq uin repoPrefix: repoPrefix, commitSeq: commitSeq, layer: layer, + size: 8, // charge the bounded source-event revision tombstone: true, } } @@ -254,6 +256,23 @@ func (p *pendingProjections) publishExpected(e, expected *projectionEntry) error // Callers prove ordering by physical sequence or the exact checked pointer. // Path-case and global file/byte caps apply equally to both publication paths. func (p *pendingProjections) publishLocked(e *projectionEntry) error { + next, err := p.projectionReplacementLocked(e) + if err != nil { + return err + } + // Physical publication cannot certify a native event that arrived after + // its write. Preserve that invalidation on the replacement. + if previous := p.snap.Load().entries[e.key]; previous != nil && previous.suspect.Load() { + e.suspect.Store(true) + } + p.snap.Store(next) + p.published.Add(1) + return nil +} + +// projectionReplacementLocked applies the common immutable-map/cap checks. +// Callers separately prove physical ordering or one checked read observation. +func (p *pendingProjections) projectionReplacementLocked(e *projectionEntry) (*projectionSnapshot, error) { cur := p.snap.Load() prev := cur.entries[e.key] bytes := cur.bytes + e.size @@ -271,15 +290,48 @@ func (p *pendingProjections) publishLocked(e *projectionEntry) error { } 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) + return nil, refusal } next := &projectionSnapshot{entries: maps.Clone(cur.entries), bytes: bytes} next.entries[e.key] = e + return next, nil +} + +// A read observation is captured before its bounded byte/absence checks. +// This local revision is neither a graph generation nor a physical receipt. +type projectionReadExpectation struct { + entry *projectionEntry + sourceEvents uint64 +} + +func (p *pendingProjections) captureReadExpectation(key string) projectionReadExpectation { + p.mu.Lock() + defer p.mu.Unlock() + entry := p.snap.Load().entries[key] + result := projectionReadExpectation{entry: entry} + if entry != nil { + result.sourceEvents = entry.sourceEvents + } + return result +} + +func (p *pendingProjections) publishReadExpected(e *projectionEntry, observed projectionReadExpectation) error { + if !p.enabled { + return projectionDisabled + } + p.mu.Lock() + defer p.mu.Unlock() + if e.commitSeq != 0 || e == observed.entry || e.suspect.Load() || p.snap.Load().entries[e.key] != observed.entry || + (observed.entry != nil && observed.entry.sourceEvents != observed.sourceEvents) { + p.refused[projectionSuperseded]++ + return projectionSuperseded + } + next, err := p.projectionReplacementLocked(e) + if err != nil { + return err + } + // The fresh bounded bytes observed all events captured before the read; + // a later event or changed owner refused above. Keep old pointers suspect. p.snap.Store(next) p.published.Add(1) return nil @@ -325,7 +377,10 @@ func (p *pendingProjections) markSuspect(path string) { if len(snap.entries) == 0 { return } - if e := snap.entries[pathkey.MutationKey(path)]; e != nil { + p.mu.Lock() + defer p.mu.Unlock() + if e := p.snap.Load().entries[pathkey.MutationKey(path)]; e != nil { + e.sourceEvents++ e.suspect.Store(true) } } diff --git a/internal/mcp/projected_read_event_revision_test.go b/internal/mcp/projected_read_event_revision_test.go index 79dc852c9..344af0fcd 100644 --- a/internal/mcp/projected_read_event_revision_test.go +++ b/internal/mcp/projected_read_event_revision_test.go @@ -63,11 +63,21 @@ func TestSourceEventDuringProjectionCannotBeClearedByOldObservation(t *testing.T err error } done := make(chan reply, 1) + joined := make(chan struct{}) go func() { + defer close(joined) entry, err := s.projectCurrentTrackedFile(ctx, ctx, path, "main.go", "main.go") done <- reply{entry, err} }() - t.Cleanup(func() { release(); <-extractor.finished }) + t.Cleanup(func() { + release() + <-joined + select { + case <-extractor.entered: + <-extractor.finished + default: + } + }) select { case <-extractor.entered: case <-ctx.Done(): @@ -105,8 +115,21 @@ func TestDeferredOldProjectionInvalidationCannotPoisonNewerPublisher(t *testing. var once sync.Once release := func() { once.Do(func() { close(extractor.release) }) } done := make(chan error, 1) - go func() { _, err := s.projectCurrentTrackedFile(ctx, ctx, path, "main.go", "main.go"); done <- err }() - t.Cleanup(func() { release(); <-extractor.finished }) + joined := make(chan struct{}) + go func() { + defer close(joined) + _, err := s.projectCurrentTrackedFile(ctx, ctx, path, "main.go", "main.go") + done <- err + }() + t.Cleanup(func() { + release() + <-joined + select { + case <-extractor.entered: + <-extractor.finished + default: + } + }) select { case <-extractor.entered: case <-ctx.Done(): diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index 548b7c6e9..1869d0f92 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -178,7 +178,8 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, if err != nil { return nil, err } - expected := pendingFileProjections.snap.Load().entries[key] + observed := pendingFileProjections.captureReadExpectation(key) + expected := observed.entry before, err := statDiskStamp(abs) if err != nil { return nil, err @@ -195,7 +196,7 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, if err != nil || latest.info != nil || pathkey.MutationKey(abs) != key { return nil, errors.Join(err, errProjectionExternalWrite) } - if err := pendingFileProjections.publishExpected(entry, expected); err != nil { + if err := pendingFileProjections.publishReadExpected(entry, observed); err != nil { return nil, err } s.admitReadBuiltProjection(requestCtx, root, idx, tracked, entry) @@ -231,13 +232,25 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, if expected != nil { if !expected.tombstone && expected.rawHash == rawHash && expected.disk.matches(after) && pathkey.MutationKey(abs) == key && pendingFileProjections.snap.Load().entries[key] == expected { - return expected, nil + if !expected.suspect.Load() { + return expected, nil + } + // Reuse the immutable parse only after bounded current-byte checks. + // This unpublished copy has no physical sequence or old invalidation; + // the old pointer stays suspect and the revision is checked at publish. + entry := expected.committedCopy(0, after) + entry.suspect.Store(false) + if err := pendingFileProjections.publishReadExpected(entry, observed); err != nil { + return nil, err + } + s.admitReadBuiltProjection(requestCtx, root, idx, tracked, entry) + return entry, nil } // Invalidate the old bytes on every exit, including cache refusal. // Do it after admission so this read's own old-version invalidation // cannot be inherited as an event against the newly verified bytes. - // Actual concurrent watcher events still set the old flag before - // publishExpected and are conservatively inherited by that publish. + // Actual concurrent watcher events advance its captured revision; + // read publication refuses them rather than clearing their evidence. defer expected.suspect.Store(true) } // Use the registered parser identity while retaining the physical @@ -261,7 +274,7 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, if readErr != nil || statErr != nil || !latest.matches(finalStamp) || blake3.Sum256(current) != rawHash || pathkey.MutationKey(abs) != key { return nil, errors.Join(readErr, statErr, errProjectionExternalWrite) } - if err := pendingFileProjections.publishExpected(entry, expected); err != nil { + if err := pendingFileProjections.publishReadExpected(entry, observed); err != nil { return nil, err } s.admitReadBuiltProjection(requestCtx, root, idx, tracked, entry) From f7cb0845410a62eacb78a3ccb6fd02df1404e01e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 08:22:31 +0100 Subject: [PATCH 33/61] fix(mcp): refuse a projection superseded during selected-view proof --- internal/mcp/projected_read_publication_test.go | 7 ++++++- internal/mcp/projected_reads.go | 6 ++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/mcp/projected_read_publication_test.go b/internal/mcp/projected_read_publication_test.go index 8147faf26..9bd664218 100644 --- a/internal/mcp/projected_read_publication_test.go +++ b/internal/mcp/projected_read_publication_test.go @@ -124,8 +124,13 @@ func TestProjectedReadKeepsSuspectInvalidationAfterBoundedReuse(t *testing.T) { pendingFileProjections.markSuspect(path) current, err := s.projectCurrentTrackedFile(t.Context(), t.Context(), path, "main.go", "main.go") require.NoError(t, err) - require.Same(t, entry, current) + require.NotSame(t, entry, current, "revalidation installs a freshly checked immutable owner") + require.Same(t, entry.layer, current.layer, "identical bytes reuse the parsed payload") require.True(t, entry.suspect.Load(), "a checked request cannot consume a watcher's invalidation for other readers/ACKs") + require.False(t, current.suspect.Load()) + require.Zero(t, current.commitSeq, "read revalidation cannot borrow a physical sequence") + require.True(t, liveProjectionForAcknowledgement(current)) + require.False(t, liveProjectionForAcknowledgement(entry)) } func TestProjectedReadReplacementDoesNotBorrowPhysicalSequence(t *testing.T) { diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index 1869d0f92..4f325f299 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -124,10 +124,8 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C } ctx, err = retainRequestProjection(ctx, entry) if err != nil { - if ctx.Err() == nil && errors.Is(err, projectionSuperseded) { - markRequestProjectionDeferred(ctx, graphPath, "external_write") - continue - } + // This entry was already selected for checked retirement/carry. + // A new owner forbids serving its old facts or falling back. return ctx, err } layers = append(layers, layer) From 0171d4d27a76caf04a366f704dbcfa133349cb27 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 08:30:30 +0100 Subject: [PATCH 34/61] test(mcp): join cancelled projection work without a discarded assignment --- internal/mcp/projected_read_event_revision_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/mcp/projected_read_event_revision_test.go b/internal/mcp/projected_read_event_revision_test.go index 344af0fcd..4a32f6c57 100644 --- a/internal/mcp/projected_read_event_revision_test.go +++ b/internal/mcp/projected_read_event_revision_test.go @@ -89,7 +89,7 @@ func TestSourceEventDuringProjectionCannotBeClearedByOldObservation(t *testing.T select { case result = <-done: case <-ctx.Done(): - result = <-done + <-done t.Fatal(ctx.Err()) } require.ErrorIs(t, result.err, projectionSuperseded, "an event after bytes were selected cannot be acknowledged by the older observation") @@ -143,7 +143,7 @@ func TestDeferredOldProjectionInvalidationCannotPoisonNewerPublisher(t *testing. select { case err = <-done: case <-ctx.Done(): - err = <-done + <-done t.Fatal(ctx.Err()) } require.ErrorIs(t, err, projectionSuperseded) From 4864f5e93649cd2473d141019c82f6d9fe65a9ad Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 08:46:12 +0100 Subject: [PATCH 35/61] test(mcp): expose cached storm reads and preselection source drift --- internal/mcp/cached_ack_source_read_test.go | 125 ++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 internal/mcp/cached_ack_source_read_test.go diff --git a/internal/mcp/cached_ack_source_read_test.go b/internal/mcp/cached_ack_source_read_test.go new file mode 100644 index 000000000..729c74520 --- /dev/null +++ b/internal/mcp/cached_ack_source_read_test.go @@ -0,0 +1,125 @@ +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/config" + "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" +) + +func TestCachedAcknowledgedSourceSurvivesQueuedStormWithoutParsing(t *testing.T) { + selectedProjectionRegistry(t) + extractor := &sourceNameCountingExtractor{Extractor: languages.NewGoExtractor()} + registry := parser.NewRegistry() + registry.Register(extractor) + s, root := setupTestServerWithRegistry(t, registry) + s.editAckMode = config.EditAckLocal + pending := make(chan indexer.MutationResult) + defer close(pending) + s.SetWatcher(mutationTestWatcher{done: pending}) + path := filepath.Join(root, "main.go") + req := makeReq("edit_file", map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() { println(7) }", "format": "json"}) + ack, err := s.runAcknowledgedEdit(t.Context(), req, s.handleEditFile) + require.NoError(t, err) + require.False(t, ack.IsError, resultText(ack)) + require.Equal(t, "committed", extractTextResult(t, ack)["disk_status"]) + cached := pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)] + require.NotNil(t, cached) + require.Positive(t, cached.commitSeq) + require.True(t, liveProjectionForAcknowledgement(cached)) + extractor.resetPaths() + s.SetWatcher(projectedStormWatcher{mutationTestWatcher: mutationTestWatcher{done: pending}, path: path}) + read := makeReq("get_symbol_source", map[string]any{"id": "main.go::helper", "context_lines": 0, "format": "json"}) + result, err := s.wrapToolHandler(s.handleGetSymbolSource)(t.Context(), read) + require.NoError(t, err) + require.False(t, result.IsError, resultText(result)) + fields := extractTextResult(t, result) + require.Equal(t, "func helper() { println(7) }", fields["source"], "queued work must not suppress an already checked committed-file projection") + require.Empty(t, extractor.snapshotPaths(), "a cached current file must not start another parse during a storm") + require.Same(t, cached, pendingFileProjections.snap.Load().entries[cached.key]) + require.Equal(t, true, fields["freshness"].(map[string]any)["readback_exact"]) +} + +func TestSymbolSourceRetriesByteDriftBeforeProjectionSelection(t *testing.T) { + selectedProjectionRegistry(t) + extractor := &pendingBudgetExtractor{base: languages.NewGoExtractor(), entered: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{})} + registry := parser.NewRegistry() + registry.Register(extractor) + s, root := setupTestServerWithRegistry(t, registry) + s.editAckMode = config.EditAckLocal + pending := make(chan indexer.MutationResult) + defer close(pending) + s.SetWatcher(mutationTestWatcher{done: pending}) + path := filepath.Join(root, "main.go") + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, []byte(strings.Replace(string(raw), "func helper() {}", "func helper() { println(1) }", 1)), 0600)) + extractor.hold.Store(true) + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + var once sync.Once + release := func() { once.Do(func() { close(extractor.release) }) } + type reply struct { + result *mcplib.CallToolResult + err error + } + responses := make(chan reply, 1) + joined := make(chan struct{}) + var deadlines []time.Time + handler := s.wrapToolHandler(func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + deadlines = append(deadlines, ctx.Value(projectedSourceDeadlineKey{}).(time.Time)) + return s.handleGetSymbolSource(ctx, req) + }) + go func() { + defer close(joined) + result, err := handler(ctx, makeReq("get_symbol_source", map[string]any{"id": "main.go::helper", "context_lines": 0, "format": "json"})) + responses <- reply{result, err} + }() + t.Cleanup(func() { + cancel() + release() + <-joined + select { + case <-extractor.entered: + <-extractor.finished + default: + } + }) + select { + case <-extractor.entered: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + write := makeReq("edit_file", map[string]any{"path": path, "old_string": "println(1)", "new_string": "println(2)", "format": "json"}) + ack, err := s.runAcknowledgedEdit(ctx, write, s.handleEditFile) + require.NoError(t, err) + require.False(t, ack.IsError, resultText(ack)) + require.Equal(t, "committed", extractTextResult(t, ack)["disk_status"]) + release() + var actual reply + select { + case actual = <-responses: + case <-ctx.Done(): + <-responses + t.Fatal(ctx.Err()) + } + require.NoError(t, actual.err) + require.NotNil(t, actual.result) + require.False(t, actual.result.IsError, resultText(actual.result)) + fields := extractTextResult(t, actual.result) + require.Equal(t, "func helper() { println(2) }", fields["source"], "a typed preselection byte mismatch gets the one audited whole-call retry") + require.Len(t, deadlines, 2) + require.Equal(t, deadlines[0], deadlines[1], "retry must not renew its original projection deadline") +} From 6db6683e0494a1850e49731abe042b4a4ab8d27e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 09:01:39 +0100 Subject: [PATCH 36/61] fix(mcp): reuse cached storm reads and retry typed preselection drift --- internal/mcp/cached_ack_source_read_test.go | 93 ++++++++++++++------- internal/mcp/projected_reads.go | 42 +++++++++- internal/mcp/tools_coding.go | 3 + 3 files changed, 104 insertions(+), 34 deletions(-) diff --git a/internal/mcp/cached_ack_source_read_test.go b/internal/mcp/cached_ack_source_read_test.go index 729c74520..567a1cf5d 100644 --- a/internal/mcp/cached_ack_source_read_test.go +++ b/internal/mcp/cached_ack_source_read_test.go @@ -20,36 +20,69 @@ import ( ) func TestCachedAcknowledgedSourceSurvivesQueuedStormWithoutParsing(t *testing.T) { - selectedProjectionRegistry(t) - extractor := &sourceNameCountingExtractor{Extractor: languages.NewGoExtractor()} - registry := parser.NewRegistry() - registry.Register(extractor) - s, root := setupTestServerWithRegistry(t, registry) - s.editAckMode = config.EditAckLocal - pending := make(chan indexer.MutationResult) - defer close(pending) - s.SetWatcher(mutationTestWatcher{done: pending}) - path := filepath.Join(root, "main.go") - req := makeReq("edit_file", map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() { println(7) }", "format": "json"}) - ack, err := s.runAcknowledgedEdit(t.Context(), req, s.handleEditFile) - require.NoError(t, err) - require.False(t, ack.IsError, resultText(ack)) - require.Equal(t, "committed", extractTextResult(t, ack)["disk_status"]) - cached := pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)] - require.NotNil(t, cached) - require.Positive(t, cached.commitSeq) - require.True(t, liveProjectionForAcknowledgement(cached)) - extractor.resetPaths() - s.SetWatcher(projectedStormWatcher{mutationTestWatcher: mutationTestWatcher{done: pending}, path: path}) - read := makeReq("get_symbol_source", map[string]any{"id": "main.go::helper", "context_lines": 0, "format": "json"}) - result, err := s.wrapToolHandler(s.handleGetSymbolSource)(t.Context(), read) - require.NoError(t, err) - require.False(t, result.IsError, resultText(result)) - fields := extractTextResult(t, result) - require.Equal(t, "func helper() { println(7) }", fields["source"], "queued work must not suppress an already checked committed-file projection") - require.Empty(t, extractor.snapshotPaths(), "a cached current file must not start another parse during a storm") - require.Same(t, cached, pendingFileProjections.snap.Load().entries[cached.key]) - require.Equal(t, true, fields["freshness"].(map[string]any)["readback_exact"]) + for _, mode := range []string{"current", "suspect_same_bytes", "changed_bytes"} { + t.Run(mode, func(t *testing.T) { + selectedProjectionRegistry(t) + extractor := &sourceNameCountingExtractor{Extractor: languages.NewGoExtractor()} + registry := parser.NewRegistry() + registry.Register(extractor) + s, root := setupTestServerWithRegistry(t, registry) + s.editAckMode = config.EditAckLocal + pending := make(chan indexer.MutationResult) + defer close(pending) + s.SetWatcher(mutationTestWatcher{done: pending}) + path := filepath.Join(root, "main.go") + req := makeReq("edit_file", map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() { println(7) }", "format": "json"}) + ack, err := s.runAcknowledgedEdit(t.Context(), req, s.handleEditFile) + require.NoError(t, err) + require.False(t, ack.IsError, resultText(ack)) + require.Equal(t, "committed", extractTextResult(t, ack)["disk_status"]) + cached := pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)] + require.NotNil(t, cached) + require.Positive(t, cached.commitSeq) + require.True(t, liveProjectionForAcknowledgement(cached)) + extractor.resetPaths() + if mode == "suspect_same_bytes" { + pendingFileProjections.markSuspect(path) + } + if mode == "changed_bytes" { + info, err := os.Stat(path) + require.NoError(t, err) + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, []byte(strings.Replace(string(raw), "println(7)", "println(8)", 1)), 0600)) + require.NoError(t, os.Chtimes(path, info.ModTime(), info.ModTime())) + } + s.SetWatcher(projectedStormWatcher{mutationTestWatcher: mutationTestWatcher{done: pending}, path: path}) + read := makeReq("get_symbol_source", map[string]any{"id": "main.go::helper", "context_lines": 0, "format": "json"}) + result, err := s.wrapToolHandler(s.handleGetSymbolSource)(t.Context(), read) + require.NoError(t, err) + require.False(t, result.IsError, resultText(result)) + fields := extractTextResult(t, result) + if mode == "changed_bytes" { + require.Nil(t, fields["source"], "a storm cannot authorize a changed cached body") + freshness := fields["freshness"].(map[string]any) + require.Equal(t, "storm", freshness["file_reason"]) + require.NotEqual(t, true, freshness["readback_exact"]) + require.Empty(t, extractor.snapshotPaths(), "actual drift must stay pending without parsing during a storm") + require.True(t, cached.suspect.Load()) + return + } + require.Equal(t, "func helper() { println(7) }", fields["source"], "queued work must not suppress an already checked committed-file projection") + require.Empty(t, extractor.snapshotPaths(), "a cached current file must not start another parse during a storm") + current := pendingFileProjections.snap.Load().entries[cached.key] + if mode == "suspect_same_bytes" { + require.NotSame(t, cached, current) + require.Same(t, cached.layer, current.layer) + require.True(t, cached.suspect.Load()) + require.False(t, current.suspect.Load()) + require.Zero(t, current.commitSeq) + } else { + require.Same(t, cached, current) + } + require.Equal(t, true, fields["freshness"].(map[string]any)["readback_exact"]) + }) + } } func TestSymbolSourceRetriesByteDriftBeforeProjectionSelection(t *testing.T) { diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index 4f325f299..0f49077b3 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -20,6 +20,21 @@ import ( const projectedReadBudget = 150 * time.Millisecond +var errProjectionStorm = errors.New("file projection deferred by queued source storm") + +func (s *Server) projectionStormActive(abs string) bool { + watcher, ok := s.currentWatcher().(interface{ StormActiveForFile(string) bool }) + return ok && watcher.StormActiveForFile(abs) +} + +func auditedSourceProjectionMismatch(ctx context.Context, err error) bool { + if ctx.Err() != nil || projectionBudgetUnavailable(ctx, err) || errors.Is(err, context.Canceled) { + return false + } + _, audited := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance) + return audited && (errors.Is(err, errProjectionExternalWrite) || errors.Is(err, projectionSuperseded)) +} + // Each request retains only the immutable file layers it actually checked in // its selected checkout. Editor buffers compose above these disk projections. type requestProjectionKey struct{} @@ -84,12 +99,15 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C if err != nil { return ctx, err } - if watcher, ok := s.currentWatcher().(interface{ StormActiveForFile(string) bool }); ok && watcher.StormActiveForFile(abs) { - markRequestProjectionStorm(ctx, graphPath) - continue - } entry, err := s.projectCurrentTrackedFile(buildCtx, ctx, abs, graphPath, rel) if err != nil { + if errors.Is(err, errProjectionStorm) { + markRequestProjectionStorm(ctx, graphPath) + continue + } + if auditedSourceProjectionMismatch(ctx, err) { + return ctx, errProjectedSourceMoved + } if projectionBudgetUnavailable(ctx, err) || ctx.Err() == nil && (errors.Is(err, errProjectionExternalWrite) || errors.Is(err, projectionSuperseded)) { markRequestProjectionDeferred(ctx, graphPath, "external_write") continue @@ -124,6 +142,9 @@ func (s *Server) ensureProjected(ctx context.Context, paths []string) (context.C } ctx, err = retainRequestProjection(ctx, entry) if err != nil { + if auditedSourceProjectionMismatch(ctx, err) { + return ctx, errProjectedSourceMoved + } // This entry was already selected for checked retirement/carry. // A new owner forbids serving its old facts or falling back. return ctx, err @@ -178,6 +199,11 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, } observed := pendingFileProjections.captureReadExpectation(key) expected := observed.entry + // A current cached payload is reusable during queued work. Without an + // existing owner, a storm must not start disk projection work at all. + if expected == nil && s.projectionStormActive(abs) { + return nil, errProjectionStorm + } before, err := statDiskStamp(abs) if err != nil { return nil, err @@ -189,6 +215,9 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, if !tracked && expected == nil { return nil, nil } + if s.projectionStormActive(abs) { + return nil, errProjectionStorm + } entry := newProjectionTombstone(abs, graphPath, idx.RepoPrefix(), 0) latest, err := statDiskStamp(abs) if err != nil || latest.info != nil || pathkey.MutationKey(abs) != key { @@ -251,6 +280,11 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, // read publication refuses them rather than clearing their evidence. defer expected.suspect.Store(true) } + // No fresh extraction during a queued storm. The matching-cache branch + // above reused only bounded, identity-checked bytes and immutable nodes. + if s.projectionStormActive(abs) { + return nil, errProjectionStorm + } // Use the registered parser identity while retaining the physical // checkout path as the registry key and disk identity. projection, err := idx.ProjectSource(buildCtx, s.overlayOwnerAbsPath(requestCtx, abs), raw) diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index bf6b4943c..ffa2453b8 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -867,6 +867,9 @@ func (s *Server) handleGetSymbolSource(ctx context.Context, req mcp.CallToolRequ if parts := strings.SplitN(id, "::", 2); len(parts) == 2 { ctx, err = s.ensureProjected(ctx, []string{parts[0]}) if err != nil { + if errors.Is(err, errProjectedSourceMoved) { + return nil, err + } return mcp.NewToolResultError(err.Error()), nil } } From 46133f0666cf90f669b5c2bdf9aa5915f117a8bc Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 14:57:08 +0100 Subject: [PATCH 37/61] fix(mcp): preserve healthy compression with checked selected source ranges --- internal/indexer/contract_convergence_test.go | 45 ++++++--- .../indexer/edit_delta_capability_test.go | 3 + .../indexer/edit_delta_context_scope_test.go | 34 +++++++ internal/mcp/read_file_compression.go | 62 ++++++++++++ internal/mcp/read_file_compression_test.go | 96 +++++++++++++++++++ internal/mcp/tools_fileops.go | 16 +++- 6 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 internal/indexer/edit_delta_context_scope_test.go create mode 100644 internal/mcp/read_file_compression.go create mode 100644 internal/mcp/read_file_compression_test.go diff --git a/internal/indexer/contract_convergence_test.go b/internal/indexer/contract_convergence_test.go index ce2382ee9..651f6348e 100644 --- a/internal/indexer/contract_convergence_test.go +++ b/internal/indexer/contract_convergence_test.go @@ -1436,10 +1436,23 @@ func TestContractAnalysisCoordinatorPublishedIsLevelTriggered(t *testing.T) { func TestContractJobRetryWaitsWhileAnOwnerIsInFlight(t *testing.T) { f := newRouteConstantFixture(t) extractions, revalidations := 0, 0 - var done chan struct{} + var release func() + held, waits, blocked := false, 0, 0 + t.Cleanup(func() { + if held { + release() + } + }) + f.onWait = func() { + if held { + waits++ + f.announce() // Unrelated acceptance wakes while the owner stays held. + } + } hooks := &contractConvergenceHooks{ revalidated: func(path string, equal bool) { if path == "fixture/routes.go" { + require.False(t, held, "no recorded read may be asked while its owner is in flight") revalidations++ require.True(t, equal, "the owner came back unchanged") } @@ -1449,29 +1462,33 @@ func TestContractJobRetryWaitsWhileAnOwnerIsInFlight(t *testing.T) { return } extractions++ - if done != nil { + if release != nil { return } - done = make(chan struct{}) - release := f.beginHeld("fixture/consts.go") - go func() { - defer close(done) - for range 3 { - time.Sleep(100 * time.Millisecond) - f.announce() - } - time.Sleep(100 * time.Millisecond) + release = f.beginHeld("fixture/consts.go") + held = true + }, + beforeAsk: func() { + if !held { + return + } + blocked++ + require.Zero(t, revalidations, "unrelated wakeups cannot re-read an in-flight owner") + if waits >= 3 { + // R0 already captured the open window. Closing it now must not + // license this pass's reads; the next pass sees acceptance. release() - }() + held = false + } }, } report, err := f.job(hooks, nil) - require.NotNil(t, done) - <-done + require.NotNil(t, release) require.NoError(t, err) require.True(t, report.Published) require.Equal(t, 1, extractions, "the route is not extracted again") require.Equal(t, 1, revalidations, "no retry reads while the owner is in flight") + require.GreaterOrEqual(t, blocked, 3, "the actual retry scans observed the held owner across unrelated wakeups") require.Contains(t, contractIDs(f.contracts()), "http::GET::/before") } diff --git a/internal/indexer/edit_delta_capability_test.go b/internal/indexer/edit_delta_capability_test.go index ae8b7c639..b3be96e27 100644 --- a/internal/indexer/edit_delta_capability_test.go +++ b/internal/indexer/edit_delta_capability_test.go @@ -66,6 +66,9 @@ var editDeltaAbsentCapabilities = map[string]string{ "graph.OverlayLayerSemanticBindingTypeReader": "checked physical-layer compiler bindings; the selected OverlaidView composes accepted file ownership, while DeltaWriter is not a persisted ancestry layer", "graph.OverlayLayerContractProjectionReader": "checked physical-layer contract projection; DeltaWriter is the composed reader that applies visibility and sibling groups, not a physical ancestry layer", "graph.OverlayLayerContractFileNodesReader": "checked physical-layer file-node projection; ContractFileNodesContext dispatches DeltaWriter explicitly through its composed view, preserving delta ownership instead of forwarding a physical layer", + "graph.FileEditingContextReadScope": "handler-only cancellable full-context read transaction; the per-save engine does not request an editing context from its mutable DeltaWriter, and WithFileEditingContextReader explicitly returns unsupported rather than borrowing a base-only transaction", + "graph.FileEditingContextLayerScope": "asserted on the immutable selected overlay layer inside a handler's full-context transaction, never on the composed mutable DeltaWriter; its delta layer cannot be forwarded as a published generation", + "graph.editingContextLayerIncoming": "private incoming-adjacency adapter asserted only on the borrowed immutable layer of an editing-context scope; DeltaWriter is not that layer and its ordinary composed incoming reads preserve delta ownership", "graph.OverlayLayerScopedNameReader": "a capability of the layers a delta composes over, not of a store: the engine asks it of each layer below the delta, and a DeltaWriter is never a layer below another delta", "graph.ReadContextBinder": "request-handler cancellation of long whole-store reads; the per-save engine never binds its store, and absent the binder returns the reader unchanged", "anonymous{Catalog}": "whole-repository or administrative operation (full index, repository reset, untrack or cleanup, warm start, vector corpus, bulk load windows, WAL maintenance); not on a file delta's per-save path", diff --git a/internal/indexer/edit_delta_context_scope_test.go b/internal/indexer/edit_delta_context_scope_test.go new file mode 100644 index 000000000..d7274408b --- /dev/null +++ b/internal/indexer/edit_delta_context_scope_test.go @@ -0,0 +1,34 @@ +package indexer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +type deltaContextScopeBase struct { + graph.Reader + called bool +} + +func (b *deltaContextScopeBase) WithFileEditingContextReader(ctx context.Context, fn func(context.Context, graph.FileEditingContextRows) error) error { + b.called = true + return fn(ctx, graph.New()) +} + +func TestDeltaWriterContextScopeCannotBorrowBasePastDelta(t *testing.T) { + base := &deltaContextScopeBase{Reader: graph.New()} + delta := graph.NewDeltaWriter(base, nil) + delta.AddNode(&graph.Node{ID: "file.go::Current", FilePath: "file.go", Kind: graph.KindFunction}) + called := false + err := graph.WithFileEditingContextReader(t.Context(), delta, func(context.Context, graph.FileEditingContextRows) error { + called = true + return nil + }) + require.ErrorIs(t, err, graph.ErrFileEditingContextUnsupported) + require.False(t, base.called, "handler-only scope cannot discard staged delta ownership") + require.False(t, called) + require.NotNil(t, delta.GetNode("file.go::Current"), "ordinary composed reads retain the delta") +} diff --git a/internal/mcp/read_file_compression.go b/internal/mcp/read_file_compression.go new file mode 100644 index 000000000..6c4d30490 --- /dev/null +++ b/internal/mcp/read_file_compression.go @@ -0,0 +1,62 @@ +package mcp + +import ( + "context" + "encoding/hex" + "time" + + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graphview" + "github.com/zzet/gortex/internal/pathkey" +) + +// A clean indexed file needs no pending projection. Bind its keep ranges to +// accepted source metadata and complete nodes from the SAME selected reader. +// Unknown evidence retains the safe uncompressed response; no base fallback. +func (s *Server) readFileCompressionSymbols(ctx context.Context, absPath, graphPath string, content []byte) ([]*graph.Node, bool) { + idx, _ := s.indexerForRel(graphPath) + if idx == nil { + return nil, false + } + key := pathkey.MutationKey(absPath) + owners := pendingFileProjections.snap.Load() + if owners.entries[key] != nil { + return nil, false // An unborrowed pending owner cannot be bypassed. + } + reader := s.requestSelectedBaseReader(ctx) + checked, ok := reader.(graph.ConstantValueProjectionReader) + if !ok { + return nil, false + } + var sources []graphview.GenerationSource + if view := requestViewFromContext(ctx); view.routed() && view.materialized != nil { + sources = view.materialized.GenerationSources() + } + revision, revisioned := reader.(interface{ MutationRevision() uint64 }) + var before uint64 + if revisioned { + before = revision.MutationRevision() + } else if len(sources) == 0 { + return nil, false // A hash ABA alone is not a stable reader witness. + } + budget, cancel := context.WithTimeout(ctx, 30*time.Millisecond) + defer cancel() + entry := &projectionEntry{repoPrefix: idx.RepoPrefix(), graphPath: graphPath} + proof := &selectedProjectionStore{ctx: budget, reader: reader, checked: checked, sources: sources, entry: entry} + rows, err := proof.FileMetasByPaths(entry.repoPrefix, []string{graphPath}) + if err != nil { + return nil, false + } + row, present := rows[graphPath] + hash := blake3.Sum256(content) + if !present || row.Size != len(content) || row.ContentHash != hex.EncodeToString(hash[:]) { + return nil, false + } + nodes := proof.GetFileNodes(graphPath) + if proof.err != nil || proof.validateProjectionRetirement() != nil || budget.Err() != nil || + revisioned && revision.MutationRevision() != before || pendingFileProjections.snap.Load() != owners { + return nil, false + } + return nodes, true +} diff --git a/internal/mcp/read_file_compression_test.go b/internal/mcp/read_file_compression_test.go new file mode 100644 index 000000000..7fce36ae4 --- /dev/null +++ b/internal/mcp/read_file_compression_test.go @@ -0,0 +1,96 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +type compressionCheckedStore struct { + graph.Store + checked graph.ConstantValueProjectionReader + nodes graph.OverlayLayerContractFileNodesReader + afterNodes func() +} + +func (s *compressionCheckedStore) ReadConstantValueProjectionContext(ctx context.Context, ids []string, keys []graph.ConstantFileKey) (graph.ConstantValueProjection, error) { + return s.checked.ReadConstantValueProjectionContext(ctx, ids, keys) +} +func (s *compressionCheckedStore) LayerContractFileNodesContext(ctx context.Context, repo string, paths []string) (map[string][]*graph.Node, error) { + rows, err := s.nodes.LayerContractFileNodesContext(ctx, repo, paths) + if s.afterNodes != nil { + s.afterNodes() + } + return rows, err +} + +type compressionRevisionStore struct{ *compressionCheckedStore } + +func (s *compressionRevisionStore) MutationRevision() uint64 { + return s.Store.(interface{ MutationRevision() uint64 }).MutationRevision() +} + +func TestReadFileCompressionBindsCanonicalBytesAndStableNodes(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupCompressTestServer(t) + path := filepath.Join(root, "service.go") + content, err := os.ReadFile(path) + require.NoError(t, err) + nodes, ok := s.readFileCompressionSymbols(t.Context(), path, "service.go", content) + require.True(t, ok, "a clean indexed file needs no cache entry to compress") + require.NotEmpty(t, nodes) + _, ok = s.readFileCompressionSymbols(t.Context(), path, "service.go", append([]byte("\n"), content...)) + require.False(t, ok, "old canonical ranges cannot bind a shifted current render") + require.Empty(t, pendingFileProjections.snap.Load().entries, "compression is not a projection publisher") +} + +func TestReadFileCompressionRejectsHashABAAndNewPendingOwner(t *testing.T) { + for _, mode := range []string{"hash_aba", "same_bytes_new_owner", "pending_owner_aba", "unknown_revision"} { + t.Run(mode, func(t *testing.T) { + selectedProjectionRegistry(t) + s, root := setupCompressTestServer(t) + path := filepath.Join(root, "service.go") + content, err := os.ReadFile(path) + require.NoError(t, err) + base := s.graph + wrapped := &compressionCheckedStore{Store: base, checked: base.(graph.ConstantValueProjectionReader), nodes: base.(graph.OverlayLayerContractFileNodesReader)} + var published *projectionEntry + if mode == "hash_aba" { + wrapped.afterNodes = func() { + nodes := base.GetFileNodes("service.go") + require.NotEmpty(t, nodes) + original := nodes[0] + changed := *original + changed.StartLine += 100 + base.AddNode(&changed) + base.AddNode(original) + } + } else if mode == "same_bytes_new_owner" || mode == "pending_owner_aba" { + wrapped.afterNodes = func() { + stamp, statErr := statDiskStamp(path) + require.NoError(t, statErr) + published = syntheticProjectionEntry(path, "service.go", "new-owner", 1, stamp, content) + require.NoError(t, pendingFileProjections.publishExpected(published, nil)) + if mode == "pending_owner_aba" { + pendingFileProjections.drop(published) + } + } + } + if mode == "unknown_revision" { + s.graph = wrapped + } else { + s.graph = &compressionRevisionStore{wrapped} + } + _, ok := s.readFileCompressionSymbols(t.Context(), path, "service.go", content) + require.False(t, ok, "matching source hashes cannot conceal changed/unknown selected ownership") + if published != nil && mode != "pending_owner_aba" { + require.Same(t, published, pendingFileProjections.snap.Load().entries[published.key]) + require.False(t, published.suspect.Load(), "refusing old ranges cannot poison a newer entry") + } + }) + } +} diff --git a/internal/mcp/tools_fileops.go b/internal/mcp/tools_fileops.go index 15c9995a4..a306b0b2a 100644 --- a/internal/mcp/tools_fileops.go +++ b/internal/mcp/tools_fileops.go @@ -1703,6 +1703,8 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* isBinary := looksBinary(content) bodiesElided := false compressionPending := false + compressionBound := false + var compressionSymbols []*graph.Node var keptSymbols []string var language string if sourceRequestView(ctx) != nil { @@ -1719,15 +1721,22 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* } if !servedFromOverlay { compressionPending = true + var entry *projectionEntry if facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts); ok { facts.mu.RLock() - entry := facts.entries[pathkey.MutationKey(absPath)] + entry = facts.entries[pathkey.MutationKey(absPath)] facts.mu.RUnlock() - compressionPending = entry == nil if entry != nil && (!liveProjectionForAcknowledgement(entry) || entry.rawHash != blake3.Sum256(diskContent)) { return mcp.NewToolResultError(errProjectedSourceMoved.Error()), nil } } + if entry != nil { + compressionSymbols = entry.layer.FileNodes(relPath) + compressionBound = true + } else { + compressionSymbols, compressionBound = s.readFileCompressionSymbols(ctx, absPath, relPath, content) + } + compressionPending = !compressionBound } } // Tool-call observer: credit the recent search for the symbols in @@ -1742,6 +1751,9 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* if sg != nil { symbols = sg.Nodes } + if compressionBound { + symbols = compressionSymbols // Never re-query ranges after their proof. + } keepPred, resolved := resolveKeepPredicate(req.GetString("keep", ""), symbols) decide := fidelityDecideForPath(fidelityRules, relPath) if out, eerr := elide.CompressWith(content, language, elide.Options{Keep: keepPred, Decide: decide}); eerr == nil && len(out) != len(content) { From cade568defb8f2cffbe4e317b74c706ebfe6a206 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 15:00:02 +0100 Subject: [PATCH 38/61] test(mcp): admit physical owner in compression supersession controls --- internal/mcp/read_file_compression_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mcp/read_file_compression_test.go b/internal/mcp/read_file_compression_test.go index 7fce36ae4..00270b7d5 100644 --- a/internal/mcp/read_file_compression_test.go +++ b/internal/mcp/read_file_compression_test.go @@ -74,7 +74,7 @@ func TestReadFileCompressionRejectsHashABAAndNewPendingOwner(t *testing.T) { stamp, statErr := statDiskStamp(path) require.NoError(t, statErr) published = syntheticProjectionEntry(path, "service.go", "new-owner", 1, stamp, content) - require.NoError(t, pendingFileProjections.publishExpected(published, nil)) + require.NoError(t, pendingFileProjections.publish(published)) if mode == "pending_owner_aba" { pendingFileProjections.drop(published) } From 5c83a0178a793a4db2fc9464e50509080e0bbb15 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 15:07:58 +0100 Subject: [PATCH 39/61] style(mcp): remove redundant compression assignment and simplify test dispatch --- internal/mcp/read_file_compression_test.go | 5 +++-- internal/mcp/tools_fileops.go | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/mcp/read_file_compression_test.go b/internal/mcp/read_file_compression_test.go index 00270b7d5..7f71d9bdd 100644 --- a/internal/mcp/read_file_compression_test.go +++ b/internal/mcp/read_file_compression_test.go @@ -59,7 +59,8 @@ func TestReadFileCompressionRejectsHashABAAndNewPendingOwner(t *testing.T) { base := s.graph wrapped := &compressionCheckedStore{Store: base, checked: base.(graph.ConstantValueProjectionReader), nodes: base.(graph.OverlayLayerContractFileNodesReader)} var published *projectionEntry - if mode == "hash_aba" { + switch mode { + case "hash_aba": wrapped.afterNodes = func() { nodes := base.GetFileNodes("service.go") require.NotEmpty(t, nodes) @@ -69,7 +70,7 @@ func TestReadFileCompressionRejectsHashABAAndNewPendingOwner(t *testing.T) { base.AddNode(&changed) base.AddNode(original) } - } else if mode == "same_bytes_new_owner" || mode == "pending_owner_aba" { + case "same_bytes_new_owner", "pending_owner_aba": wrapped.afterNodes = func() { stamp, statErr := statDiskStamp(path) require.NoError(t, statErr) diff --git a/internal/mcp/tools_fileops.go b/internal/mcp/tools_fileops.go index a306b0b2a..74988e817 100644 --- a/internal/mcp/tools_fileops.go +++ b/internal/mcp/tools_fileops.go @@ -1720,7 +1720,6 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* return mcp.NewToolResultError(err.Error()), nil } if !servedFromOverlay { - compressionPending = true var entry *projectionEntry if facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts); ok { facts.mu.RLock() From 330ad9ac800746f844844f03d522c79dbd660a1b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 15:11:54 +0100 Subject: [PATCH 40/61] test(mcp): expose shared generation zero compression ABA --- .../mcp/read_file_compression_gen0_test.go | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 internal/mcp/read_file_compression_gen0_test.go diff --git a/internal/mcp/read_file_compression_gen0_test.go b/internal/mcp/read_file_compression_gen0_test.go new file mode 100644 index 000000000..cb974c97e --- /dev/null +++ b/internal/mcp/read_file_compression_gen0_test.go @@ -0,0 +1,83 @@ +package mcp + +import ( + "context" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/graph" +) + +type compressionSelectedBase struct { + graph.Reader + afterNodes func() + revisionCalls int + unknown bool +} + +func (r *compressionSelectedBase) ReadConstantValueProjectionContext(ctx context.Context, ids []string, keys []graph.ConstantFileKey) (graph.ConstantValueProjection, error) { + return r.Reader.(graph.ConstantValueProjectionReader).ReadConstantValueProjectionContext(ctx, ids, keys) +} +func (r *compressionSelectedBase) LayerContractFileNodesContext(ctx context.Context, repo string, paths []string) (map[string][]*graph.Node, error) { + rows, err := graph.ContractFileNodesContext(ctx, r.Reader, repo, paths) + if r.afterNodes != nil { + r.afterNodes() + } + return rows, err +} +func (r *compressionSelectedBase) SearchMutationRevision() (uint64, bool) { + r.revisionCalls++ + if r.unknown { + return 0, false + } + return r.Reader.(interface{ SearchMutationRevision() (uint64, bool) }).SearchMutationRevision() +} + +func TestReadFileCompressionSelectedGen0MutationCannotHideBehindLayerProof(t *testing.T) { + for _, mode := range []string{"quiet", "gen0_node_aba", "unknown_base"} { + t.Run(mode, func(t *testing.T) { + selectedProjectionRegistry(t) + stack := newViewStack(t) + path := filepath.Join(stack.worktreeRoot, "unowned.go") + content := []byte("package repo\n\nfunc Canonical() {}\n") + require.NoError(t, os.WriteFile(path, content, 0600)) + base := stack.store.AtGeneration(0) + node := viewRepoNode("repo/unowned.go::Canonical", "Canonical", graph.KindFunction, "repo/unowned.go", 3) + base.AddNode(node) + hash := blake3.Sum256(content) + require.NoError(t, base.SetFileMetas("repo", []graph.FileMetaRow{{FilePath: "repo/unowned.go", ContentHash: hex.EncodeToString(hash[:]), Size: len(content), NodeCount: 1}})) + ctx, view := routedEnrichmentView(t, stack) + for _, source := range view.materialized.GenerationSources() { + require.NotZero(t, source.GenerationID, "the actual leased source list omits shared gen0") + } + wrapped := &compressionSelectedBase{Reader: view.reader, unknown: mode == "unknown_base"} + view.reader = wrapped + hooked := false + if mode == "gen0_node_aba" { + wrapped.afterNodes = func() { + hooked = true + before := base.MutationRevision() + changed := *node + changed.StartLine += 100 + base.AddNode(&changed) + base.AddNode(node) + require.Greater(t, base.MutationRevision(), before) + } + } + _, ok := stack.srv.readFileCompressionSymbols(ctx, path, "repo/unowned.go", content) + if mode == "quiet" { + require.True(t, ok, "healthy selected compression must still read unowned gen0 nodes") + } else { + require.False(t, ok, "unchanged file hashes/nonzero layers cannot witness mutable gen0") + } + if mode == "gen0_node_aba" { + require.True(t, hooked, "the real selected full-node read reached gen0 before the interleaving") + } + require.Positive(t, wrapped.revisionCalls, "the composed reader's actual base witness must be consulted") + }) + } +} From 474c3d4270c61c7026a6fbdffc5fb7ca50f1366a Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 15:12:55 +0100 Subject: [PATCH 41/61] test(mcp): pin the selected generation source field and actual node ABA --- internal/mcp/read_file_compression_gen0_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/mcp/read_file_compression_gen0_test.go b/internal/mcp/read_file_compression_gen0_test.go index cb974c97e..a5c0cb396 100644 --- a/internal/mcp/read_file_compression_gen0_test.go +++ b/internal/mcp/read_file_compression_gen0_test.go @@ -52,7 +52,7 @@ func TestReadFileCompressionSelectedGen0MutationCannotHideBehindLayerProof(t *te require.NoError(t, base.SetFileMetas("repo", []graph.FileMetaRow{{FilePath: "repo/unowned.go", ContentHash: hex.EncodeToString(hash[:]), Size: len(content), NodeCount: 1}})) ctx, view := routedEnrichmentView(t, stack) for _, source := range view.materialized.GenerationSources() { - require.NotZero(t, source.GenerationID, "the actual leased source list omits shared gen0") + require.NotZero(t, source.Generation, "the actual leased source list omits shared gen0") } wrapped := &compressionSelectedBase{Reader: view.reader, unknown: mode == "unknown_base"} view.reader = wrapped @@ -64,7 +64,9 @@ func TestReadFileCompressionSelectedGen0MutationCannotHideBehindLayerProof(t *te changed := *node changed.StartLine += 100 base.AddNode(&changed) + require.Equal(t, changed.StartLine, base.GetNode(node.ID).StartLine) base.AddNode(node) + require.Equal(t, node.StartLine, base.GetNode(node.ID).StartLine) require.Greater(t, base.MutationRevision(), before) } } From b082899fbc03ee60ab633196af1398ba12fee1f9 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 15:15:12 +0100 Subject: [PATCH 42/61] fix(mcp): witness shared generation zero during compression binding --- internal/mcp/read_file_compression.go | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/internal/mcp/read_file_compression.go b/internal/mcp/read_file_compression.go index 6c4d30490..c8b5a7276 100644 --- a/internal/mcp/read_file_compression.go +++ b/internal/mcp/read_file_compression.go @@ -33,12 +33,20 @@ func (s *Server) readFileCompressionSymbols(ctx context.Context, absPath, graphP if view := requestViewFromContext(ctx); view.routed() && view.materialized != nil { sources = view.materialized.GenerationSources() } - revision, revisioned := reader.(interface{ MutationRevision() uint64 }) - var before uint64 - if revisioned { - before = revision.MutationRevision() - } else if len(sources) == 0 { - return nil, false // A hash ABA alone is not a stable reader witness. + baseRevision := func() (uint64, bool) { + if revision, ok := reader.(interface{ MutationRevision() uint64 }); ok { + return revision.MutationRevision(), true + } + if revision, ok := reader.(interface{ SearchMutationRevision() (uint64, bool) }); ok { + return revision.SearchMutationRevision() + } + return 0, false + } + before, known := baseRevision() + if !known { + // GenerationSources excludes the mutable shared gen0 beneath the + // composed layers. Its SAME-reader base witness is required too. + return nil, false } budget, cancel := context.WithTimeout(ctx, 30*time.Millisecond) defer cancel() @@ -54,8 +62,11 @@ func (s *Server) readFileCompressionSymbols(ctx context.Context, absPath, graphP return nil, false } nodes := proof.GetFileNodes(graphPath) - if proof.err != nil || proof.validateProjectionRetirement() != nil || budget.Err() != nil || - revisioned && revision.MutationRevision() != before || pendingFileProjections.snap.Load() != owners { + if proof.err != nil || proof.validateProjectionRetirement() != nil || budget.Err() != nil { + return nil, false + } + after, stillKnown := baseRevision() + if !stillKnown || after != before || pendingFileProjections.snap.Load() != owners { return nil, false } return nodes, true From 18f95b6ae1e6c8c756c104fa7fb391a4dbba6f92 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 16:17:39 +0100 Subject: [PATCH 43/61] test(mcp): prepare canonical promotion late-read interleaving controls --- internal/mcp/canonical_promotion_read_test.go | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 internal/mcp/canonical_promotion_read_test.go diff --git a/internal/mcp/canonical_promotion_read_test.go b/internal/mcp/canonical_promotion_read_test.go new file mode 100644 index 000000000..c733230a9 --- /dev/null +++ b/internal/mcp/canonical_promotion_read_test.go @@ -0,0 +1,173 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "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/graph/store_sqlite" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/query" +) + +func canonicalPromotionReadFixture(t *testing.T) (*Server, *store_sqlite.Store, string) { + t.Helper() + root := t.TempDir() + path := filepath.Join(root, "main.go") + require.NoError(t, os.WriteFile(path, []byte("package probe\n\nfunc Target() int { return 1 }\n\nfunc Neighbor() int { return 9 }\n"), 0600)) + store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "canonical.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + idx := newProjectionTestIndexer(t, store) + _, err = idx.Index(root) + require.NoError(t, err) + s := NewServer(query.NewEngine(store), store, idx, nil, zap.NewNop(), nil) + s.editAckMode = config.EditAckLocal + return s, store, path +} + +// Hypothesis prepared from the older-pin diagnostic, not a reproduction claim: +// ordinary canonical promotion must be distinguished from a newer physical +// owner or native invalidation. The leaf-return seam deterministically places +// real publication/retirement before the existing final freshness decorator. +func TestSymbolSourceCanonicalPromotionAfterLeafUsesBoundedFreshAttempt(t *testing.T) { + for _, transition := range []string{"canonical_promotion", "receipt_only", "peer_same_bytes_commit", "same_bytes_event"} { + t.Run(transition, func(t *testing.T) { + selectedProjectionRegistry(t) + s, store, path := canonicalPromotionReadFixture(t) + pending := make(chan indexer.MutationResult) + var receipt *mutationReceipt + t.Cleanup(func() { + close(pending) + if receipt == nil { + return + } + select { + case <-receipt.done: + case <-time.After(3 * time.Second): + t.Error("owned held ticket did not join before fixture cleanup") + } + }) + s.SetWatcher(mutationTestWatcher{done: pending}) + const body = "func Target() int {\n\tvalue := 7\n\treturn value\n}" + ack, err := s.runAcknowledgedEdit(t.Context(), makeReq("edit_file", map[string]any{ + "path": path, "old_string": "func Target() int { return 1 }", "new_string": body, "format": "json", + }), s.handleEditFile) + require.NoError(t, err) + require.False(t, ack.IsError, resultText(ack)) + fields := extractTextResult(t, ack) + require.Equal(t, "local", fields["ack_mode"]) + require.Equal(t, "committed", fields["disk_status"]) + value, found := s.mutationReceipts.Load(fields["reindex_receipt"].(string)) + require.True(t, found) + receipt = value.(*mutationReceipt) + entry := liveEntry(pendingFileProjections, path) + require.NotNil(t, entry) + require.Positive(t, entry.commitSeq) + require.True(t, liveProjectionForAcknowledgement(entry)) + require.Equal(t, 3, store.GetNode("main.go::Target").StartLine) + require.Equal(t, 5, store.GetNode("main.go::Neighbor").StartLine, "canonical graph still precedes the acknowledged line shift") + after, err := os.ReadFile(path) + require.NoError(t, err) + sequence, layer := entry.commitSeq, entry.layer + retirement := s.canonicalProjectionRetirement(path) + require.NotNil(t, retirement) + + originalDeadline := time.Now().Add(projectedReadBudget) + ctx := context.WithValue(t.Context(), projectedSourceDeadlineKey{}, originalDeadline) + calls := 0 + var first context.Context + var deadlines []time.Time + leaf := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + calls++ + deadlines = append(deadlines, ctx.Value(projectedSourceDeadlineKey{}).(time.Time)) + result, readErr := s.handleGetSymbolSource(ctx, req) + require.NoError(t, readErr) + require.NotNil(t, result) + require.False(t, result.IsError, resultText(result)) + require.Equal(t, body, extractTextResult(t, result)["source"]) + if calls != 1 { + return result, nil + } + first = ctx + require.True(t, s.hasRequestProjectionFact(ctx, "main.go"), "the real source handler must borrow the ACK owner before the transition") + switch transition { + case "canonical_promotion": + // Actual parser/store publication, not a fabricated Done result, + // matching hash alone, peer edit or manual registry deletion. + require.NoError(t, s.indexer.IndexFile(path)) + require.Equal(t, 8, store.GetNode("main.go::Neighbor").StartLine) + rows, metaErr := store.FileMetasByPaths(entry.repoPrefix, []string{entry.graphPath}) + require.NoError(t, metaErr) + require.Equal(t, entry.hash, rows[entry.graphPath].ContentHash) + retirement.run() + require.Nil(t, liveEntry(pendingFileProjections, path)) + require.Positive(t, pendingFileProjections.retired.Load()) + require.False(t, entry.suspect.Load(), "canonical promotion is not a native invalidation") + case "receipt_only": + retirement.run() + require.Same(t, entry, liveEntry(pendingFileProjections, path), "an unadvanced canonical store cannot retire the acknowledged bytes") + require.Zero(t, pendingFileProjections.retired.Load()) + case "peer_same_bytes_commit": + held, release, lockErr := acquireMutationPath(t.Context(), path) + require.NoError(t, lockErr) + commit, writeErr := s.commitFileMutation(held, "write_file", "", "", "main.go", path, after, 0600) + release() + require.NoError(t, writeErr) + require.Greater(t, commit.commitSeq, sequence) + require.NotSame(t, entry, liveEntry(pendingFileProjections, path)) + require.Equal(t, entry.rawHash, liveEntry(pendingFileProjections, path).rawHash) + case "same_bytes_event": + pendingFileProjections.markSuspect(path) + require.Same(t, entry, liveEntry(pendingFileProjections, path)) + require.True(t, entry.suspect.Load()) + } + require.Equal(t, sequence, entry.commitSeq) + require.Same(t, layer, entry.layer) + current, diskErr := os.ReadFile(path) + require.NoError(t, diskErr) + require.Equal(t, after, current, "the retirement/event controls must never introduce another physical edit") + if transition != "receipt_only" { + require.ErrorIs(t, validateRequestProjections(first), projectionSuperseded, "the old context must remain unusable; a fresh attempt cannot rebind its facts") + } + return result, nil + } + result, err := s.wrapToolHandler(leaf)(ctx, makeReq("get_symbol_source", map[string]any{ + "id": "main.go::Target", "context_lines": 0, "format": "json", + })) + require.NoError(t, err) + require.NotNil(t, result) + t.Logf("transition=%s handler_attempts=%d final_is_error=%v", transition, calls, result.IsError) + require.LessOrEqual(t, calls, 2, "late validation cannot renew the whole-call allowance") + for _, deadline := range deadlines { + require.Equal(t, originalDeadline, deadline) + } + facts := first.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + facts.mu.RLock() + borrowed := facts.entries[entry.key] + facts.mu.RUnlock() + require.Same(t, entry, borrowed, "another attempt cannot rebind the first selection's facts") + switch transition { + case "canonical_promotion": + require.False(t, result.IsError, "verified canonical promotion should allow one coherent fresh source attempt: %s", resultText(result)) + require.Equal(t, 2, calls, "the old selection stays invalid; the successful response must come from a new whole attempt") + require.Equal(t, body, extractTextResult(t, result)["source"]) + require.ErrorIs(t, validateRequestProjections(first), projectionSuperseded) + case "receipt_only": + require.False(t, result.IsError, resultText(result)) + require.Equal(t, 1, calls) + default: + require.True(t, result.IsError, "a peer owner or invalidating event cannot be treated as canonical promotion") + require.Contains(t, resultText(result), "superseded") + require.Equal(t, 1, calls) + } + }) + } +} From 193f70af69fd42c0f7b7a153a174b321ba313398 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 17:14:04 +0100 Subject: [PATCH 44/61] fix(mcp): retry source reads after checked projection retirement --- .../mcp/canonical_promotion_budget_test.go | 110 ++++++++++++++++++ internal/mcp/canonical_promotion_read_test.go | 54 +++++++-- internal/mcp/checked_retirement_hint_test.go | 85 ++++++++++++++ internal/mcp/overlay.go | 3 + internal/mcp/pending_projection.go | 32 ++++- internal/mcp/pending_projection_cohort.go | 3 + internal/mcp/projected_source_retry.go | 41 ++++++- 7 files changed, 316 insertions(+), 12 deletions(-) create mode 100644 internal/mcp/canonical_promotion_budget_test.go create mode 100644 internal/mcp/checked_retirement_hint_test.go diff --git a/internal/mcp/canonical_promotion_budget_test.go b/internal/mcp/canonical_promotion_budget_test.go new file mode 100644 index 000000000..59767ba9d --- /dev/null +++ b/internal/mcp/canonical_promotion_budget_test.go @@ -0,0 +1,110 @@ +package mcp + +import ( + "context" + "os" + "strings" + "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 TestRepeatedCheckedRetirementCannotRenewSourceAllowance(t *testing.T) { + selectedProjectionRegistry(t) + s, store, path := canonicalPromotionReadFixture(t) + pending := make(chan indexer.MutationResult) + s.SetWatcher(mutationTestWatcher{done: pending}) + var receipt *mutationReceipt + t.Cleanup(func() { + close(pending) + if receipt != nil { + select { + case <-receipt.done: + case <-time.After(3 * time.Second): + t.Error("owned held ticket did not join") + } + } + }) + ack, err := s.runAcknowledgedEdit(t.Context(), makeReq("edit_file", map[string]any{ + "path": path, "old_string": "return 1", "new_string": "return 7", "format": "json", + }), s.handleEditFile) + require.NoError(t, err) + require.False(t, ack.IsError, resultText(ack)) + value, found := s.mutationReceipts.Load(extractTextResult(t, ack)["reindex_receipt"].(string)) + require.True(t, found) + receipt = value.(*mutationReceipt) + prepared := liveEntry(pendingFileProjections, path) + require.NotNil(t, prepared) + require.Positive(t, prepared.commitSeq) + before, err := os.ReadFile(path) + require.NoError(t, err) + current := string(before) + originalDeadline := time.Now().Add(projectedReadBudget) + ctx := context.WithValue(t.Context(), projectedSourceDeadlineKey{}, originalDeadline) + var entries []*projectionEntry + var contexts []context.Context + calls := 0 + leaf := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + calls++ + t.Logf("attempt=%d before_leaf_remaining=%s", calls, time.Until(originalDeadline)) + require.Equal(t, originalDeadline, ctx.Value(projectedSourceDeadlineKey{})) + result, err := s.handleGetSymbolSource(ctx, req) + require.NoError(t, err) + require.False(t, result.IsError, resultText(result)) + t.Logf("attempt=%d after_leaf_remaining=%s", calls, time.Until(originalDeadline)) + entry := liveEntry(pendingFileProjections, path) + require.NotNil(t, entry) + require.True(t, s.hasRequestProjectionFact(ctx, "main.go")) + entries = append(entries, entry) + contexts = append(contexts, ctx) + retirement := s.canonicalProjectionRetirement(path) + require.NotNil(t, retirement) + // Publish the already checked full rows in the real canonical SQLite + // backend. The original interleaving test covers IndexFile orchestration; + // this control isolates two retirements from its unrelated cold work. + nodes := entry.layer.FileNodes(entry.graphPath) + store.AddBatch(nodes, nil) + require.NoError(t, store.SetFileMetas(entry.repoPrefix, []graph.FileMetaRow{{ + FilePath: entry.graphPath, ContentHash: entry.hash, NodeCount: len(nodes), + }})) + require.Equal(t, nodes[0].ID, store.GetNode(nodes[0].ID).ID) + retirement.run() + t.Logf("attempt=%d after_retirement_remaining=%s", calls, time.Until(originalDeadline)) + require.True(t, entry.checkedRetired.Load()) + require.Nil(t, liveEntry(pendingFileProjections, path)) + if calls == 1 { + // A second real source version forces the fresh attempt to select + // another projection, which can independently catch up and retire. + current = strings.Replace(current, "return 7", "return 8", 1) + require.NoError(t, os.WriteFile(path, []byte(current), 0600)) + } + return result, nil + } + result, err := s.wrapToolHandler(leaf)(ctx, makeReq("get_symbol_source", map[string]any{ + "id": "main.go::Target", "context_lines": 0, "format": "json", + })) + require.NoError(t, err) + require.NotNil(t, result) + t.Logf("handler_attempts=%d final=%s remaining_original_budget=%s", calls, resultText(result), time.Until(originalDeadline)) + require.True(t, result.IsError, "two retirements cannot authorize a third fresh attempt") + require.Equal(t, 2, calls) + require.NotSame(t, entries[0], entries[1]) + for index, observed := range contexts { + if observed.Err() != nil { + require.ErrorIs(t, observed.Err(), context.Canceled) + require.ErrorIs(t, validateRequestProjections(observed), context.Canceled) + } else { + require.ErrorIs(t, validateRequestProjections(observed), projectionSuperseded) + } + facts := observed.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + facts.mu.RLock() + borrowed := facts.entries[entries[index].key] + facts.mu.RUnlock() + require.Same(t, entries[index], borrowed) + } +} diff --git a/internal/mcp/canonical_promotion_read_test.go b/internal/mcp/canonical_promotion_read_test.go index c733230a9..6b25b8c45 100644 --- a/internal/mcp/canonical_promotion_read_test.go +++ b/internal/mcp/canonical_promotion_read_test.go @@ -15,10 +15,14 @@ import ( "github.com/zzet/gortex/internal/graph/store_sqlite" "github.com/zzet/gortex/internal/indexer" "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/tokens" ) func canonicalPromotionReadFixture(t *testing.T) (*Server, *store_sqlite.Store, string) { t.Helper() + // Daemon startup warms token counting before it serves source requests. + // Keep that one-time encoder initialization outside the read allowance. + require.True(t, tokens.EncoderReady()) root := t.TempDir() path := filepath.Join(root, "main.go") require.NoError(t, os.WriteFile(path, []byte("package probe\n\nfunc Target() int { return 1 }\n\nfunc Neighbor() int { return 9 }\n"), 0600)) @@ -38,7 +42,7 @@ func canonicalPromotionReadFixture(t *testing.T) (*Server, *store_sqlite.Store, // owner or native invalidation. The leaf-return seam deterministically places // real publication/retirement before the existing final freshness decorator. func TestSymbolSourceCanonicalPromotionAfterLeafUsesBoundedFreshAttempt(t *testing.T) { - for _, transition := range []string{"canonical_promotion", "receipt_only", "peer_same_bytes_commit", "same_bytes_event"} { + for _, transition := range []string{"canonical_promotion", "receipt_only", "unchecked_eviction", "peer_same_bytes_commit", "peer_retired", "same_bytes_event", "event_then_retire", "canonical_cancelled"} { t.Run(transition, func(t *testing.T) { selectedProjectionRegistry(t) s, store, path := canonicalPromotionReadFixture(t) @@ -81,7 +85,9 @@ func TestSymbolSourceCanonicalPromotionAfterLeafUsesBoundedFreshAttempt(t *testi require.NotNil(t, retirement) originalDeadline := time.Now().Add(projectedReadBudget) - ctx := context.WithValue(t.Context(), projectedSourceDeadlineKey{}, originalDeadline) + caller, cancel := context.WithCancel(t.Context()) + defer cancel() + ctx := context.WithValue(caller, projectedSourceDeadlineKey{}, originalDeadline) calls := 0 var first context.Context var deadlines []time.Time @@ -99,7 +105,7 @@ func TestSymbolSourceCanonicalPromotionAfterLeafUsesBoundedFreshAttempt(t *testi first = ctx require.True(t, s.hasRequestProjectionFact(ctx, "main.go"), "the real source handler must borrow the ACK owner before the transition") switch transition { - case "canonical_promotion": + case "canonical_promotion", "canonical_cancelled": // Actual parser/store publication, not a fabricated Done result, // matching hash alone, peer edit or manual registry deletion. require.NoError(t, s.indexer.IndexFile(path)) @@ -111,11 +117,20 @@ func TestSymbolSourceCanonicalPromotionAfterLeafUsesBoundedFreshAttempt(t *testi require.Nil(t, liveEntry(pendingFileProjections, path)) require.Positive(t, pendingFileProjections.retired.Load()) require.False(t, entry.suspect.Load(), "canonical promotion is not a native invalidation") + require.True(t, time.Now().Before(originalDeadline), "canonical retirement must fit the original read allowance") + require.True(t, entry.checkedRetired.Load()) + if transition == "canonical_cancelled" { + cancel() + } case "receipt_only": retirement.run() require.Same(t, entry, liveEntry(pendingFileProjections, path), "an unadvanced canonical store cannot retire the acknowledged bytes") require.Zero(t, pendingFileProjections.retired.Load()) - case "peer_same_bytes_commit": + case "unchecked_eviction": + pendingFileProjections.drop(entry) + require.Nil(t, liveEntry(pendingFileProjections, path)) + require.False(t, entry.checkedRetired.Load()) + case "peer_same_bytes_commit", "peer_retired": held, release, lockErr := acquireMutationPath(t.Context(), path) require.NoError(t, lockErr) commit, writeErr := s.commitFileMutation(held, "write_file", "", "", "main.go", path, after, 0600) @@ -124,17 +139,31 @@ func TestSymbolSourceCanonicalPromotionAfterLeafUsesBoundedFreshAttempt(t *testi require.Greater(t, commit.commitSeq, sequence) require.NotSame(t, entry, liveEntry(pendingFileProjections, path)) require.Equal(t, entry.rawHash, liveEntry(pendingFileProjections, path).rawHash) - case "same_bytes_event": + if transition == "peer_retired" { + newer := liveEntry(pendingFileProjections, path) + require.NoError(t, s.indexer.IndexFile(path)) + retirement.run() + require.Nil(t, liveEntry(pendingFileProjections, path)) + require.True(t, newer.checkedRetired.Load()) + require.False(t, entry.checkedRetired.Load(), "another owner's retirement cannot authorize this old selection") + } + case "same_bytes_event", "event_then_retire": pendingFileProjections.markSuspect(path) require.Same(t, entry, liveEntry(pendingFileProjections, path)) require.True(t, entry.suspect.Load()) + if transition == "event_then_retire" { + require.NoError(t, s.indexer.IndexFile(path)) + retirement.run() + require.Nil(t, liveEntry(pendingFileProjections, path)) + require.False(t, entry.checkedRetired.Load()) + } } require.Equal(t, sequence, entry.commitSeq) require.Same(t, layer, entry.layer) current, diskErr := os.ReadFile(path) require.NoError(t, diskErr) require.Equal(t, after, current, "the retirement/event controls must never introduce another physical edit") - if transition != "receipt_only" { + if transition != "receipt_only" && transition != "canonical_cancelled" { require.ErrorIs(t, validateRequestProjections(first), projectionSuperseded, "the old context must remain unusable; a fresh attempt cannot rebind its facts") } return result, nil @@ -142,6 +171,12 @@ func TestSymbolSourceCanonicalPromotionAfterLeafUsesBoundedFreshAttempt(t *testi result, err := s.wrapToolHandler(leaf)(ctx, makeReq("get_symbol_source", map[string]any{ "id": "main.go::Target", "context_lines": 0, "format": "json", })) + if transition == "canonical_cancelled" { + require.True(t, err != nil || result != nil && result.IsError, "cancellation cannot become an accepted result") + require.Equal(t, 1, calls) + require.ErrorIs(t, caller.Err(), context.Canceled) + return + } require.NoError(t, err) require.NotNil(t, result) t.Logf("transition=%s handler_attempts=%d final_is_error=%v", transition, calls, result.IsError) @@ -159,7 +194,12 @@ func TestSymbolSourceCanonicalPromotionAfterLeafUsesBoundedFreshAttempt(t *testi require.False(t, result.IsError, "verified canonical promotion should allow one coherent fresh source attempt: %s", resultText(result)) require.Equal(t, 2, calls, "the old selection stays invalid; the successful response must come from a new whole attempt") require.Equal(t, body, extractTextResult(t, result)["source"]) - require.ErrorIs(t, validateRequestProjections(first), projectionSuperseded) + if first.Err() != nil { + require.ErrorIs(t, first.Err(), context.Canceled, "the discarded attempt's lease was released") + require.ErrorIs(t, validateRequestProjections(first), context.Canceled) + } else { + require.ErrorIs(t, validateRequestProjections(first), projectionSuperseded) + } case "receipt_only": require.False(t, result.IsError, resultText(result)) require.Equal(t, 1, calls) diff --git a/internal/mcp/checked_retirement_hint_test.go b/internal/mcp/checked_retirement_hint_test.go new file mode 100644 index 000000000..6a30de2fc --- /dev/null +++ b/internal/mcp/checked_retirement_hint_test.go @@ -0,0 +1,85 @@ +package mcp + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +type checkedRetirementNodeProbe struct { + projectionStore + afterNodes func() +} + +func (s *checkedRetirementNodeProbe) GetFileNodes(path string) []*graph.Node { + nodes := s.projectionStore.GetFileNodes(path) + if s.afterNodes != nil { + s.afterNodes() + } + return nodes +} + +func TestCheckedRetirementHintRequiresExactUnchangedProof(t *testing.T) { + for _, mode := range []string{"checked", "newer_owner", "event_then_legacy_lookup", "divergent_nodes"} { + t.Run(mode, func(t *testing.T) { + selectedProjectionRegistry(t) + fixture := newProjectionFixture(t) + entry := fixture.edit("a.go", "package p\n\nfunc Published() {}\n") + require.NoError(t, pendingFileProjections.publish(entry)) + require.NoError(t, fixture.idx.IndexFile(filepath.Join(fixture.dir, "a.go"))) + probe := &checkedRetirementNodeProbe{projectionStore: fixture.g} + var newer *projectionEntry + switch mode { + case "newer_owner": + newer = entry.committedCopy(entry.commitSeq+1, entry.disk) + probe.afterNodes = func() { require.NoError(t, pendingFileProjections.publish(newer)) } + case "event_then_legacy_lookup": + probe.afterNodes = func() { + pendingFileProjections.markSuspect(entry.absPath) + // The real legacy rehash clears suspect for identical bytes. + // Its success cannot erase the checked retirement's event fence. + found, err := pendingFileProjections.lookup(entry.absPath) + require.NoError(t, err) + require.Same(t, entry, found) + require.False(t, entry.suspect.Load()) + } + case "divergent_nodes": + node := *fixture.g.GetNode("a.go::Published") + node.StartLine += 100 + fixture.g.AddNode(&node) + } + retired := pendingFileProjections.retirePaths(probe, []string{entry.absPath}) + if mode == "newer_owner" { + require.Zero(t, retired) + require.Same(t, newer, liveEntry(pendingFileProjections, entry.absPath)) + require.False(t, newer.checkedRetired.Load()) + } else { + require.Equal(t, 1, retired, "existing retirement/drop behavior remains unchanged") + require.Nil(t, liveEntry(pendingFileProjections, entry.absPath)) + } + require.Equal(t, mode == "checked", entry.checkedRetired.Load()) + }) + } +} + +func TestCheckedRetirementHintDoesNotFollowNewOwnerOrPermitRepublish(t *testing.T) { + selectedProjectionRegistry(t) + fixture := newProjectionFixture(t) + entry := fixture.edit("a.go", "package p\n\nfunc Published() {}\n") + require.NoError(t, pendingFileProjections.publish(entry)) + require.NoError(t, fixture.idx.IndexFile(entry.absPath)) + require.Equal(t, 1, pendingFileProjections.retirePaths(fixture.g, []string{entry.absPath})) + require.True(t, entry.checkedRetired.Load()) + require.ErrorIs(t, pendingFileProjections.publish(entry), projectionSuperseded) + require.ErrorIs(t, pendingFileProjections.publishReadExpected(entry, projectionReadExpectation{}), projectionSuperseded) + require.ErrorIs(t, pendingFileProjections.publishCohort([]projectionCohortMember{{Entry: entry}}), projectionSuperseded) + newer := entry.committedCopy(entry.commitSeq+1, entry.disk) + require.False(t, newer.checkedRetired.Load(), "the hint belongs only to the checked historical owner") + require.Equal(t, entry.size, newer.size) + require.NoError(t, pendingFileProjections.publish(newer)) + require.Same(t, newer, liveEntry(pendingFileProjections, entry.absPath)) + require.True(t, entry.checkedRetired.Load(), "republishing a new owner must not mutate the historical hint") +} diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index f35b23217..8989acd69 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -444,6 +444,9 @@ 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 legacyName == "get_symbol_source" && res != nil && !res.IsError && retrySourceAfterCheckedRetirement(ctx) { + return nil, errProjectedSourceMoved + } 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 — diff --git a/internal/mcp/pending_projection.go b/internal/mcp/pending_projection.go index 032338529..95454da2d 100644 --- a/internal/mcp/pending_projection.go +++ b/internal/mcp/pending_projection.go @@ -84,6 +84,8 @@ type projectionEntry struct { // re-reads the file before serving the entry. suspect atomic.Bool sourceEvents uint64 // source-event revision, guarded by pendingProjections.mu + // Permission for one fresh source attempt, never freshness of this old owner. + checkedRetired atomic.Bool } // projectionRefusal is why a publish left the live entries as they were. @@ -186,7 +188,7 @@ func newProjectionEntry(absPath, repoPrefix string, projection *indexer.Projecti parseErrors: projection.ParseErrors, parseChecked: projection.ParseChecked, buildTime: projection.BuildTime, - size: int64(len(projection.Nodes))*projectionNodeBytes + int64(len(projection.Edges))*projectionEdgeBytes + 8, + size: int64(len(projection.Nodes))*projectionNodeBytes + int64(len(projection.Edges))*projectionEdgeBytes + 16, } } @@ -218,7 +220,7 @@ func newProjectionTombstone(absPath, graphPath, repoPrefix string, commitSeq uin repoPrefix: repoPrefix, commitSeq: commitSeq, layer: layer, - size: 8, // charge the bounded source-event revision + size: 16, // charge the source-event revision and checked-retirement hint tombstone: true, } } @@ -285,6 +287,8 @@ func (p *pendingProjections) projectionReplacementLocked(e *projectionEntry) (*p } var refusal projectionRefusal switch { + case e.checkedRetired.Load(): + refusal = projectionSuperseded // historical owners cannot be republished case prev != nil && prev.graphPath != e.graphPath: refusal = projectionPathCase case prev == nil && len(cur.entries) >= projectionMaxFiles: @@ -471,11 +475,14 @@ func (p *pendingProjections) retire(store projectionStore) int { // 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 { + p.mu.Lock() snap := p.snap.Load() if len(snap.entries) == 0 { + p.mu.Unlock() return 0 } byRepo := make(map[string][]*projectionEntry) + revisions := make(map[*projectionEntry]uint64, len(snap.entries)) for key, e := range snap.entries { if selected != nil { if _, ok := selected[key]; !ok { @@ -483,7 +490,9 @@ func (p *pendingProjections) retireSelected(store projectionStore, selected map[ } } byRepo[e.repoPrefix] = append(byRepo[e.repoPrefix], e) + revisions[e] = e.sourceEvents } + p.mu.Unlock() // metadata/node/layer reads stay outside the registry lock var caughtUp []*projectionEntry for prefix, entries := range byRepo { candidates := storeHolds(store, prefix, entries) @@ -495,6 +504,7 @@ func (p *pendingProjections) retireSelected(store projectionStore, selected map[ for _, e := range storeHolds(store, prefix, candidates) { if diverged[e] { p.divergence.Add(1) + delete(revisions, e) // dropping a divergent payload grants no retry hint } caughtUp = append(caughtUp, e) } @@ -505,11 +515,27 @@ func (p *pendingProjections) retireSelected(store projectionStore, selected map[ return 0 } } - retired := p.dropAll(caughtUp) + retired := p.dropCheckedRetired(caughtUp, revisions) p.retired.Add(int64(retired)) return retired } +func (p *pendingProjections) dropCheckedRetired(entries []*projectionEntry, revisions map[*projectionEntry]uint64) int { + if len(entries) == 0 { + return 0 + } + p.mu.Lock() + defer p.mu.Unlock() + current := p.snap.Load() + for _, entry := range entries { + if revision, observed := revisions[entry]; observed && current.entries[entry.key] == entry && + entry.sourceEvents == revision && !entry.suspect.Load() { + entry.checkedRetired.Store(true) + } + } + return p.dropAllLocked(entries) +} + // storeHolds returns the entries whose content the store holds now. func storeHolds(store projectionStore, prefix string, entries []*projectionEntry) []*projectionEntry { if len(entries) == 0 { diff --git a/internal/mcp/pending_projection_cohort.go b/internal/mcp/pending_projection_cohort.go index e28dd4624..4716e55ad 100644 --- a/internal/mcp/pending_projection_cohort.go +++ b/internal/mcp/pending_projection_cohort.go @@ -36,6 +36,9 @@ func (p *pendingProjections) publishCohort(members []projectionCohortMember) err if entry == nil || entry.key == "" || entry.graphPath == "" || entry.size < 0 { return errors.New("projection cohort contains invalid entry") } + if entry.checkedRetired.Load() { + return refuse(projectionSuperseded) + } if _, duplicate := seen[entry.key]; duplicate { return errors.New("projection cohort contains duplicate physical path") } diff --git a/internal/mcp/projected_source_retry.go b/internal/mcp/projected_source_retry.go index 70e707e26..8c26d8611 100644 --- a/internal/mcp/projected_source_retry.go +++ b/internal/mcp/projected_source_retry.go @@ -52,8 +52,45 @@ func (a *projectedSourceAllowance) claim() bool { } } -// Only this audited source read retries, once, for a typed byte mismatch. Each -// attempt runs the whole middleware with fresh facts/layers and releases its +// Permission to revalidate from scratch, not freshness authority for an old +// response. Peer owners, invalidation and unchecked eviction remain refusals. +func retrySourceAfterCheckedRetirement(ctx context.Context) bool { + if ctx.Err() != nil { + return false + } + if _, ok := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance); !ok { + return false + } + deadline, ok := ctx.Value(projectedSourceDeadlineKey{}).(time.Time) + if !ok || !time.Now().Before(deadline) { + return false + } + facts, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + if !ok { + return false + } + facts.mu.RLock() + defer facts.mu.RUnlock() + retired := false + for _, entry := range facts.entries { + if entry == nil { + return false + } + current := pendingFileProjections.snap.Load().entries[entry.key] + if current == entry && liveProjectionForAcknowledgement(entry) { + continue + } + if current != nil || !entry.checkedRetired.Load() || entry.suspect.Load() { + return false + } + retired = true + } + return retired +} + +// Only this audited source read retries, once, for a typed byte mismatch or +// checked retirement. Each attempt runs the whole middleware with fresh +// facts/layers and releases its // leases before the next; the first attempt's projection deadline is retained. func (s *Server) retryProjectedSymbolSource(attempt mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { From cc09a8061e898e92def76e695153b4a3da6f339a Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 17:33:00 +0100 Subject: [PATCH 45/61] test(mcp): cover canonical promotion during summary and editing context --- .../canonical_promotion_file_reads_test.go | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 internal/mcp/canonical_promotion_file_reads_test.go diff --git a/internal/mcp/canonical_promotion_file_reads_test.go b/internal/mcp/canonical_promotion_file_reads_test.go new file mode 100644 index 000000000..f243c82b1 --- /dev/null +++ b/internal/mcp/canonical_promotion_file_reads_test.go @@ -0,0 +1,169 @@ +package mcp + +import ( + "context" + "go/ast" + goparser "go/parser" + "go/token" + "os" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/indexer" +) + +func acknowledgedCanonicalFile(t *testing.T) (*Server, string, *projectionEntry, []byte) { + t.Helper() + s, _, path := canonicalPromotionReadFixture(t) + pending := make(chan indexer.MutationResult) + var receipt *mutationReceipt + t.Cleanup(func() { + close(pending) + if receipt != nil { + select { + case <-receipt.done: + case <-time.After(3 * time.Second): + t.Error("owned held ticket did not join") + } + } + }) + s.SetWatcher(mutationTestWatcher{done: pending}) + ack, err := s.runAcknowledgedEdit(t.Context(), makeReq("edit_file", map[string]any{ + "path": path, "old_string": "func Target() int { return 1 }", + "new_string": "func Target() int {\n\tvalue := 7\n\treturn value\n}", "format": "json", + }), s.handleEditFile) + require.NoError(t, err) + require.False(t, ack.IsError, resultText(ack)) + fields := extractTextResult(t, ack) + require.Equal(t, "local", fields["ack_mode"]) + require.Equal(t, "committed", fields["disk_status"]) + value, found := s.mutationReceipts.Load(fields["reindex_receipt"].(string)) + require.True(t, found) + receipt = value.(*mutationReceipt) + entry := liveEntry(pendingFileProjections, path) + require.NotNil(t, entry) + require.Positive(t, entry.commitSeq) + raw, err := os.ReadFile(path) + require.NoError(t, err) + return s, path, entry, raw +} + +func requireCanonicalDefinitionRows(t *testing.T, tool string, result *mcplib.CallToolResult, raw []byte) { + t.Helper() + require.NotNil(t, result) + require.False(t, result.IsError, resultText(result)) + field := "nodes" + if tool == "get_editing_context" { + field = "defines" + } + rows, ok := extractTextResult(t, result)[field].([]any) + require.True(t, ok) + byID := make(map[string]map[string]any) + for _, value := range rows { + row := value.(map[string]any) + byID[row["id"].(string)] = row + } + files := token.NewFileSet() + parsed, err := goparser.ParseFile(files, "main.go", raw, 0) + require.NoError(t, err) + functions := 0 + for _, declaration := range parsed.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok { + continue + } + functions++ + row := byID["main.go::"+function.Name.Name] + require.NotNil(t, row, "local definitions cannot substitute for the required function") + require.Equal(t, function.Name.Name, row["name"]) + require.Equal(t, "function", row["kind"]) + require.Equal(t, float64(files.Position(function.Pos()).Line), row["start_line"]) + if tool == "get_file_summary" { + require.Equal(t, float64(files.Position(function.End()).Line), row["end_line"]) + } + } + require.Equal(t, 2, functions, "the independent source oracle includes target and shifted neighbor") +} + +func TestCanonicalPromotionFileReadsKeepIndependentDefinitionOracle(t *testing.T) { + for _, tool := range []string{"get_file_summary", "get_editing_context"} { + for _, transition := range []string{"canonical_promotion", "peer_same_bytes", "same_bytes_event", "unchecked_eviction"} { + t.Run(tool+"/"+transition, func(t *testing.T) { + selectedProjectionRegistry(t) + s, path, entry, raw := acknowledgedCanonicalFile(t) + retirement := s.canonicalProjectionRetirement(path) + require.NotNil(t, retirement) + deadline := time.Now().Add(projectedReadBudget) + ctx := context.WithValue(t.Context(), projectedSourceDeadlineKey{}, deadline) + beforeCount := s.tokenStatsFor(t.Context()).snapshot()["calls_counted"].(int64) + calls := 0 + var first context.Context + leaf := func(ctx context.Context, request mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + calls++ + require.Equal(t, deadline, ctx.Value(projectedSourceDeadlineKey{})) + var result *mcplib.CallToolResult + var err error + if tool == "get_file_summary" { + result, err = s.handleGetFileSummary(ctx, request) + } else { + result, err = s.handleGetEditingContext(ctx, request) + } + require.NoError(t, err) + requireCanonicalDefinitionRows(t, tool, result, raw) + if calls != 1 { + return result, nil + } + first = ctx + require.True(t, s.hasRequestProjectionFact(ctx, "main.go")) + switch transition { + case "canonical_promotion": + require.Equal(t, 5, s.graph.GetNode("main.go::Neighbor").StartLine) + require.NoError(t, s.indexer.IndexFile(path)) + require.Equal(t, 8, s.graph.GetNode("main.go::Neighbor").StartLine) + retirement.run() + require.True(t, entry.checkedRetired.Load()) + require.Nil(t, liveEntry(pendingFileProjections, path)) + require.True(t, time.Now().Before(deadline)) + case "peer_same_bytes": + held, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + commit, writeErr := s.commitFileMutation(held, "write_file", "", "", "main.go", path, raw, 0600) + release() + require.NoError(t, writeErr) + require.Greater(t, commit.commitSeq, entry.commitSeq) + case "same_bytes_event": + pendingFileProjections.markSuspect(path) + case "unchecked_eviction": + pendingFileProjections.drop(entry) + require.False(t, entry.checkedRetired.Load()) + } + require.ErrorIs(t, validateRequestProjections(first), projectionSuperseded) + return result, nil + } + result, err := s.wrapToolHandler(leaf)(ctx, makeReq(tool, map[string]any{"path": "main.go", "format": "json"})) + require.NoError(t, err) + require.NotNil(t, result) + t.Logf("tool=%s transition=%s attempts=%d error=%v", tool, transition, calls, result.IsError) + require.LessOrEqual(t, calls, 2) + facts := first.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + facts.mu.RLock() + borrowed := facts.entries[entry.key] + facts.mu.RUnlock() + require.Same(t, entry, borrowed) + if transition == "canonical_promotion" { + requireCanonicalDefinitionRows(t, tool, result, raw) + require.Equal(t, 2, calls) + afterCount := s.tokenStatsFor(t.Context()).snapshot()["calls_counted"].(int64) + require.Equal(t, int64(1), afterCount-beforeCount, "discarded attempts must not book a second accepted file read") + } else { + require.True(t, result.IsError) + require.Contains(t, resultText(result), "superseded") + require.Equal(t, 1, calls) + } + }) + } + } +} From 6211ba4e8ee428c99563d1509424a2daeb3251de Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 17:57:48 +0100 Subject: [PATCH 46/61] fix: retry checked canonical retirement in file metadata reads --- .../mcp/canonical_file_read_budget_test.go | 36 +++++++++++ .../canonical_promotion_file_reads_test.go | 59 +++++++++++++++++-- internal/mcp/contract_core_editing_context.go | 6 +- internal/mcp/overlay.go | 17 +++++- internal/mcp/projected_reads.go | 5 +- internal/mcp/projected_source_retry.go | 47 ++++++++++++--- internal/mcp/tools_coding.go | 19 ++++-- internal/mcp/tools_fileops.go | 7 ++- 8 files changed, 172 insertions(+), 24 deletions(-) create mode 100644 internal/mcp/canonical_file_read_budget_test.go diff --git a/internal/mcp/canonical_file_read_budget_test.go b/internal/mcp/canonical_file_read_budget_test.go new file mode 100644 index 000000000..61d1896fe --- /dev/null +++ b/internal/mcp/canonical_file_read_budget_test.go @@ -0,0 +1,36 @@ +package mcp + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +type remainingFileReadBudget struct { + graph.Store + observed time.Time +} + +func (r *remainingFileReadBudget) WithFileEditingContextReader(ctx context.Context, _ func(context.Context, graph.FileEditingContextRows) error) error { + r.observed, _ = ctx.Deadline() + <-ctx.Done() + return ctx.Err() +} + +func TestFileStoreScopeCannotRenewOriginalProjectionDeadline(t *testing.T) { + deadline := time.Now().Add(10 * time.Millisecond) + ctx := context.WithValue(t.Context(), projectedSourceDeadlineKey{}, deadline) + reader := &remainingFileReadBudget{Store: graph.New()} + consumed := false + err := withFileStoreRows(ctx, reader, func(graph.FileEditingContextRows) error { + consumed = true + return nil + }) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Equal(t, deadline, reader.observed, "a second 30ms store scope cannot extend the original remaining allowance") + require.False(t, consumed, "an unavailable scope cannot supply partial canonical rows") +} diff --git a/internal/mcp/canonical_promotion_file_reads_test.go b/internal/mcp/canonical_promotion_file_reads_test.go index f243c82b1..87e111d63 100644 --- a/internal/mcp/canonical_promotion_file_reads_test.go +++ b/internal/mcp/canonical_promotion_file_reads_test.go @@ -88,21 +88,42 @@ func requireCanonicalDefinitionRows(t *testing.T, tool string, result *mcplib.Ca require.Equal(t, 2, functions, "the independent source oracle includes target and shifted neighbor") } +func canonicalReadFrecency(s *Server, id string) int { + s.frecency.mu.Lock() + defer s.frecency.mu.Unlock() + if index := s.frecency.findLocked(id); index >= 0 { + return len(s.frecency.store.Symbols[index].Times) + } + return 0 +} + func TestCanonicalPromotionFileReadsKeepIndependentDefinitionOracle(t *testing.T) { for _, tool := range []string{"get_file_summary", "get_editing_context"} { - for _, transition := range []string{"canonical_promotion", "peer_same_bytes", "same_bytes_event", "unchecked_eviction"} { + transitions := []string{"canonical_promotion", "peer_same_bytes", "same_bytes_event", "unchecked_eviction", "cancelled_promotion"} + if tool == "get_editing_context" { + transitions = append(transitions, "compression_not_audited") + } + for _, transition := range transitions { t.Run(tool+"/"+transition, func(t *testing.T) { selectedProjectionRegistry(t) s, path, entry, raw := acknowledgedCanonicalFile(t) + s.InitFrecency("", "", ModeAI) retirement := s.canonicalProjectionRetirement(path) require.NotNil(t, retirement) deadline := time.Now().Add(projectedReadBudget) - ctx := context.WithValue(t.Context(), projectedSourceDeadlineKey{}, deadline) + caller, cancel := context.WithCancel(t.Context()) + defer cancel() + ctx := context.WithValue(caller, projectedSourceDeadlineKey{}, deadline) beforeCount := s.tokenStatsFor(t.Context()).snapshot()["calls_counted"].(int64) + beforeFrecency := canonicalReadFrecency(s, "main.go::Target") calls := 0 var first context.Context + cancelledLeafDone := make(chan struct{}) leaf := func(ctx context.Context, request mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { calls++ + if transition == "cancelled_promotion" { + defer close(cancelledLeafDone) + } require.Equal(t, deadline, ctx.Value(projectedSourceDeadlineKey{})) var result *mcplib.CallToolResult var err error @@ -119,7 +140,7 @@ func TestCanonicalPromotionFileReadsKeepIndependentDefinitionOracle(t *testing.T first = ctx require.True(t, s.hasRequestProjectionFact(ctx, "main.go")) switch transition { - case "canonical_promotion": + case "canonical_promotion", "compression_not_audited", "cancelled_promotion": require.Equal(t, 5, s.graph.GetNode("main.go::Neighbor").StartLine) require.NoError(t, s.indexer.IndexFile(path)) require.Equal(t, 8, s.graph.GetNode("main.go::Neighbor").StartLine) @@ -141,13 +162,33 @@ func TestCanonicalPromotionFileReadsKeepIndependentDefinitionOracle(t *testing.T require.False(t, entry.checkedRetired.Load()) } require.ErrorIs(t, validateRequestProjections(first), projectionSuperseded) + if transition == "cancelled_promotion" { + cancel() + } return result, nil } - result, err := s.wrapToolHandler(leaf)(ctx, makeReq(tool, map[string]any{"path": "main.go", "format": "json"})) + args := map[string]any{"path": "main.go", "format": "json"} + if transition == "compression_not_audited" { + args["compress_bodies"] = true + } + result, err := s.wrapToolHandler(leaf)(ctx, makeReq(tool, args)) + if transition == "cancelled_promotion" { + require.True(t, err != nil || result != nil && result.IsError) + select { + case <-cancelledLeafDone: + case <-time.After(3 * time.Second): + t.Fatal("owned canceled handler did not join") + } + require.Equal(t, 1, calls) + require.ErrorIs(t, caller.Err(), context.Canceled) + require.Equal(t, beforeCount, s.tokenStatsFor(t.Context()).snapshot()["calls_counted"]) + return + } require.NoError(t, err) require.NotNil(t, result) t.Logf("tool=%s transition=%s attempts=%d error=%v", tool, transition, calls, result.IsError) require.LessOrEqual(t, calls, 2) + require.Error(t, validateRequestProjections(first), "the original selection must remain unusable") facts := first.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) facts.mu.RLock() borrowed := facts.entries[entry.key] @@ -158,10 +199,20 @@ func TestCanonicalPromotionFileReadsKeepIndependentDefinitionOracle(t *testing.T require.Equal(t, 2, calls) afterCount := s.tokenStatsFor(t.Context()).snapshot()["calls_counted"].(int64) require.Equal(t, int64(1), afterCount-beforeCount, "discarded attempts must not book a second accepted file read") + if tool == "get_editing_context" { + require.Equal(t, 1, canonicalReadFrecency(s, "main.go::Target")-beforeFrecency) + } } else { require.True(t, result.IsError) require.Contains(t, resultText(result), "superseded") require.Equal(t, 1, calls) + if transition != "compression_not_audited" { + afterCount := s.tokenStatsFor(t.Context()).snapshot()["calls_counted"].(int64) + require.Equal(t, int64(0), afterCount-beforeCount, "refused answers cannot book file savings") + if tool == "get_editing_context" { + require.Equal(t, beforeFrecency, canonicalReadFrecency(s, "main.go::Target")) + } + } } }) } diff --git a/internal/mcp/contract_core_editing_context.go b/internal/mcp/contract_core_editing_context.go index 516e306f0..d3136023a 100644 --- a/internal/mcp/contract_core_editing_context.go +++ b/internal/mcp/contract_core_editing_context.go @@ -53,7 +53,11 @@ func fileEditingContextContext(ctx context.Context, reader graph.Reader, path st // withFileStoreRows gives every section of one file response the SAME 30ms // cancellable, coherent selected-store scope. No legacy API is a fallback. func withFileStoreRows(ctx context.Context, reader graph.Reader, consume func(graph.FileEditingContextRows) error) error { - ctx, cancel := context.WithTimeout(ctx, 30*time.Millisecond) + deadline := time.Now().Add(30 * time.Millisecond) + if original, ok := ctx.Value(projectedSourceDeadlineKey{}).(time.Time); ok && original.Before(deadline) { + deadline = original + } + ctx, cancel := context.WithDeadline(ctx, deadline) defer cancel() selected := reader var core *contractCoreEdges diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index 8989acd69..05021f3e5 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -213,6 +213,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // A facade call is lowered to its legacy name once here; both of the // gates below read that name rather than resolving it twice. legacyName, _ := s.legacyToolName(&req) + auditedProjectionRead := auditedProjectionReadRetry(legacyName, req) controlOperation := checkoutControlOperationName(legacyName) batchContinuation := s.isBatchContinuation(legacyName, &req) if batchContinuation { @@ -411,7 +412,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay return refused, nil } } - if hErr == nil && res != nil && !res.IsError { + if hErr == nil && res != nil && !res.IsError && !auditedProjectionRead { res, hErr = s.commitFreshSymbolPage(ctx, res, req.Params.Name, view) if state, _ := ctx.Value(freshSymbolAttemptKey{}).(*freshSymbolAttempt); state != nil && state.withdrawn { s.recorder.Record("mcp_tool_call", req.Params.Name) @@ -444,7 +445,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 legacyName == "get_symbol_source" && res != nil && !res.IsError && retrySourceAfterCheckedRetirement(ctx) { + if auditedProjectionRead && res != nil && !res.IsError && retrySourceAfterCheckedRetirement(ctx) { return nil, errProjectedSourceMoved } res = s.decorateToolFreshness(ctx, req.Params.Name, req, res) @@ -455,6 +456,18 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay res = s.attachCheckoutControlScope(ctx, res) res = decorateContractConsumerResult(ctx, res) } + // These nonpaginated reads commit their existing bookkeeping buffer + // only after the last ownership check, including decorator-time I/O. + if auditedProjectionRead && hErr == nil && res != nil && !res.IsError { + if err := validateRequestProjections(ctx); err != nil { + if retrySourceAfterCheckedRetirement(ctx) { + return nil, errProjectedSourceMoved + } + res = mcp.NewToolResultError(err.Error()) + } else { + res, hErr = s.commitFreshSymbolPage(ctx, res, req.Params.Name, view) + } + } // The arg guard's warn rider lands here — after the warming and // freshness decorators, both of which rebuild the text result from // Content[0] and would drop a rider block attached any earlier. This diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index 0f49077b3..51b8753ac 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -31,8 +31,9 @@ func auditedSourceProjectionMismatch(ctx context.Context, err error) bool { if ctx.Err() != nil || projectionBudgetUnavailable(ctx, err) || errors.Is(err, context.Canceled) { return false } - _, audited := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance) - return audited && (errors.Is(err, errProjectionExternalWrite) || errors.Is(err, projectionSuperseded)) + allowance, audited := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance) + return audited && (allowance.tool == "" || allowance.tool == "get_symbol_source") && + (errors.Is(err, errProjectionExternalWrite) || errors.Is(err, projectionSuperseded)) } // Each request retains only the immutable file layers it actually checked in diff --git a/internal/mcp/projected_source_retry.go b/internal/mcp/projected_source_retry.go index 8c26d8611..1fb663b60 100644 --- a/internal/mcp/projected_source_retry.go +++ b/internal/mcp/projected_source_retry.go @@ -20,12 +20,46 @@ func invalidateProjectedSource(entry *projectionEntry) error { type projectedSourceDeadlineKey struct{} type projectedSourceAllowanceKey struct{} -type projectedSourceAllowance struct{ attempts atomic.Int32 } +type projectedSourceAllowance struct { + attempts atomic.Int32 + tool string +} + +// Only these nonpaginated file reads have audited replay-safe response work. +// Editing-context compression still uses its legacy physical source reader; +// it keeps the existing refusal behavior rather than gaining another attempt. +func auditedProjectionReadRetry(tool string, req mcplib.CallToolRequest) bool { + switch tool { + case "get_symbol_source", "get_file_summary": + return true + case "get_editing_context": + args := req.GetArguments() + var options map[string]any + if value, present := args["options"]; present { + var valid bool + options, valid = value.(map[string]any) + if !valid { + return false + } + } + for _, values := range []map[string]any{args, options} { + if value, present := values["compress_bodies"]; present { + compressed, valid := value.(bool) + if !valid || compressed { + return false + } + } + } + return true + default: + return false + } +} func (s *Server) withProjectedSourceReadBudget(next mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { legacy, known := s.legacyToolName(&req) - if !known || legacy != "get_symbol_source" { + if !known || !auditedProjectionReadRetry(legacy, req) { return next(ctx, req) } if _, exists := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance); !exists { @@ -34,7 +68,7 @@ func (s *Server) withProjectedSourceReadBudget(next mcpserver.ToolHandlerFunc) m deadline = prior } ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, deadline) - ctx = context.WithValue(ctx, projectedSourceAllowanceKey{}, &projectedSourceAllowance{}) + ctx = context.WithValue(ctx, projectedSourceAllowanceKey{}, &projectedSourceAllowance{tool: legacy}) } return next(ctx, req) } @@ -88,10 +122,9 @@ func retrySourceAfterCheckedRetirement(ctx context.Context) bool { return retired } -// Only this audited source read retries, once, for a typed byte mismatch or -// checked retirement. Each attempt runs the whole middleware with fresh -// facts/layers and releases its -// leases before the next; the first attempt's projection deadline is retained. +// Audited file reads retry once after checked retirement; source also retains +// its typed byte-mismatch retry. Every attempt has fresh facts/layers/leases +// under the original shared deadline and two-attempt allowance. func (s *Server) retryProjectedSymbolSource(attempt mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { allowance, ok := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance) diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index ffa2453b8..f8acfa47b 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -370,10 +370,11 @@ func (s *Server) handleGetEditingContext(ctx context.Context, req mcp.CallToolRe return mcp.NewToolResultError(err.Error()), nil } - s.sessionFor(ctx).recordFile(fp) - // Tool-call observer: credit the recent search for the symbols in - // the file the agent is about to edit. - s.creditFileConsumption(ctx, fp) + afterFreshSymbolAcceptance(ctx, func() { + s.sessionFor(ctx).recordFile(fp) + // Credit the file consume only if this response is accepted. + s.creditFileConsumption(ctx, fp) + }) out := editingContext{} var fileNodeForScope *graph.Node callerCap := 20 @@ -398,9 +399,15 @@ func (s *Server) handleGetEditingContext(ctx context.Context, req mcp.CallToolRe if fileNodeForScope != nil && !s.nodeInSessionScope(ctx, fileNodeForScope) { return mcp.NewToolResultError("no symbols found for file: " + fp), nil } - for _, n := range bundle.Defines { - s.frecency.Record(n.ID) + consumedIDs := make([]string, len(bundle.Defines)) + for i, n := range bundle.Defines { + consumedIDs[i] = n.ID } + afterFreshSymbolAcceptance(ctx, func() { + for _, id := range consumedIDs { + s.frecency.Record(id) + } + }) if bundle.FileNode != nil { out.File = map[string]any{"id": bundle.FileNode.ID, "language": bundle.FileNode.Language} } diff --git a/internal/mcp/tools_fileops.go b/internal/mcp/tools_fileops.go index 74988e817..3418aca11 100644 --- a/internal/mcp/tools_fileops.go +++ b/internal/mcp/tools_fileops.go @@ -773,8 +773,11 @@ func (s *Server) recordFileBaselineSavings(ctx context.Context, tool, relPath, l // This charges the whole file as the counterfactual, so claim it for the // session: a later retrieval page that merely cites the file must not // bill it a second time (savings_retrieval.go). - stats.creditFile(abs) - stats.record(s.fileAttributionNode(relPath, language), tool, returned, fullFile) + node := s.fileAttributionNode(relPath, language) + afterFreshSymbolAcceptance(ctx, func() { + stats.creditFile(abs) + stats.record(node, tool, returned, fullFile) + }) } // pendingSavings is one source-read observation assembled before an From dcf581e5b7f95c065809df2cbd4bc8cb64183a95 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 18:37:21 +0100 Subject: [PATCH 47/61] test(mcp): trace registered reads across preselection retirement --- ...registered_preselection_retirement_test.go | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 internal/mcp/registered_preselection_retirement_test.go diff --git a/internal/mcp/registered_preselection_retirement_test.go b/internal/mcp/registered_preselection_retirement_test.go new file mode 100644 index 000000000..da1f65146 --- /dev/null +++ b/internal/mcp/registered_preselection_retirement_test.go @@ -0,0 +1,124 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "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/query" + "github.com/zzet/gortex/internal/tokens" +) + +// Observe one real metadata read without replacing the selected store's other +// capabilities. The callback is armed only after the registered same-session +// edit and preliminary reads; its old result is a legitimate pre-publication +// observation, not a fabricated hash or node row. +type preselectionRetirementStore struct { + *store_sqlite.Store + armed atomic.Bool + reads atomic.Int32 + publish func() +} + +func (s *preselectionRetirementStore) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { + rows, err := s.Store.FileMetasByPaths(prefix, paths) + if err == nil && s.armed.CompareAndSwap(true, false) { + s.reads.Add(1) + s.publish() + } + return rows, err +} + +func TestRegisteredPreselectionRetirementKeepsCurrentFileRows(t *testing.T) { + for _, tool := range []string{"get_file_summary", "get_editing_context"} { + t.Run(tool, func(t *testing.T) { + selectedProjectionRegistry(t) + require.True(t, tokens.EncoderReady()) + root := t.TempDir() + path := filepath.Join(root, "main.go") + require.NoError(t, os.WriteFile(path, []byte("package probe\n\nfunc Target() int { return 1 }\n\nfunc Neighbor() int { return 9 }\n"), 0600)) + sql, err := store_sqlite.Open(filepath.Join(t.TempDir(), "registered.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, sql.Close()) }) + backend := &preselectionRetirementStore{Store: sql} + idx := newProjectionTestIndexer(t, backend) + _, err = idx.Index(root) + require.NoError(t, err) + s := NewServer(query.NewEngine(backend), backend, idx, nil, zap.NewNop(), nil) + s.editAckMode = config.EditAckLocal + pending := make(chan indexer.MutationResult) + s.SetWatcher(mutationTestWatcher{done: pending}) + var receipt *mutationReceipt + t.Cleanup(func() { + close(pending) + if receipt != nil { + select { + case <-receipt.done: + case <-time.After(3 * time.Second): + t.Error("owned held ticket did not join") + } + } + }) + session := "registered-preselection-" + tool + require.NoError(t, s.ConnectSession(session, func([]byte) error { return nil })) + t.Cleanup(func() { s.DisconnectSession(session) }) + initializeSession(t, s, session) + s.NoteSessionToolPolicy(session, "all", "defer") + ctx := s.WithClientSession(t.Context(), session) + ack := guardE2ECall(t, s, ctx, 10, "edit_file", map[string]any{"path": path, "old_string": "func Target() int { return 1 }", "new_string": "func Target() int {\n\tvalue := 7\n\treturn value\n}", "format": "json"}) + require.False(t, ack.IsError, resultText(ack)) + fields := extractTextResult(t, ack) + require.Equal(t, "local", fields["ack_mode"]) + require.Equal(t, true, fields["readback_exact"]) + value, ok := s.mutationReceipts.Load(fields["reindex_receipt"].(string)) + require.True(t, ok) + receipt = value.(*mutationReceipt) + entry := liveEntry(pendingFileProjections, path) + require.NotNil(t, entry) + require.Positive(t, entry.commitSeq) + raw, err := os.ReadFile(path) + require.NoError(t, err) + // Use the real registered all/defer operations, retaining one connection. + for i, name := range []string{"get_symbol", "read_file", "get_symbol_source"} { + args := map[string]any{"format": "json"} + if name == "read_file" { + args["path"] = "main.go" + } else { + args["id"] = "main.go::Target" + if name == "get_symbol" { + args["detail"] = "full" + } + } + result := guardE2ECall(t, s, ctx, 11+i, name, args) + require.False(t, result.IsError, resultText(result)) + } + retirement := s.canonicalProjectionRetirement(path) + require.NotNil(t, retirement) + backend.publish = func() { + require.Equal(t, 5, sql.GetNode("main.go::Neighbor").StartLine) + require.NoError(t, idx.IndexFile(path)) + require.Equal(t, 8, sql.GetNode("main.go::Neighbor").StartLine) + retirement.run() + require.True(t, entry.checkedRetired.Load()) + require.Nil(t, liveEntry(pendingFileProjections, path)) + } + deadline := time.Now().Add(projectedReadBudget) + ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, deadline) + backend.armed.Store(true) + result := guardE2ECall(t, s, ctx, 20, tool, map[string]any{"path": "main.go", "format": "json"}) + require.Equal(t, int32(1), backend.reads.Load(), "actual preselection metadata seam must be reached") + t.Logf("registered tool=%s result=%s remaining_original_budget=%s", tool, resultText(result), time.Until(deadline)) + requireCanonicalDefinitionRows(t, tool, result, raw) + }) + } +} From 1238bb8a04620c01109da208fd2ff74ec278a6f9 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 18:42:13 +0100 Subject: [PATCH 48/61] test(mcp): use actual daemon promotion before registered frames --- .../registered_preselection_retirement_test.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/mcp/registered_preselection_retirement_test.go b/internal/mcp/registered_preselection_retirement_test.go index da1f65146..266590954 100644 --- a/internal/mcp/registered_preselection_retirement_test.go +++ b/internal/mcp/registered_preselection_retirement_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + mcplib "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -39,6 +40,15 @@ func (s *preselectionRetirementStore) FileMetasByPaths(prefix string, paths []st return rows, err } +// This is the production daemon's promote-on-demand and per-call authorization +// before registered MCP dispatch, not a direct leaf call. +func preselectionRegisteredCall(t *testing.T, s *Server, ctx context.Context, id int, name string, args map[string]any) *mcplib.CallToolResult { + t.Helper() + require.True(t, s.IsToolEnabledForSession(ctx, name), "the entire initialized connection uses all/defer") + s.EnsureToolPromotedForSession(ctx, name) + return guardE2ECall(t, s, WithAuthorizedToolCall(ctx, name), id, name, args) +} + func TestRegisteredPreselectionRetirementKeepsCurrentFileRows(t *testing.T) { for _, tool := range []string{"get_file_summary", "get_editing_context"} { t.Run(tool, func(t *testing.T) { @@ -75,7 +85,7 @@ func TestRegisteredPreselectionRetirementKeepsCurrentFileRows(t *testing.T) { initializeSession(t, s, session) s.NoteSessionToolPolicy(session, "all", "defer") ctx := s.WithClientSession(t.Context(), session) - ack := guardE2ECall(t, s, ctx, 10, "edit_file", map[string]any{"path": path, "old_string": "func Target() int { return 1 }", "new_string": "func Target() int {\n\tvalue := 7\n\treturn value\n}", "format": "json"}) + ack := preselectionRegisteredCall(t, s, ctx, 10, "edit_file", map[string]any{"path": path, "old_string": "func Target() int { return 1 }", "new_string": "func Target() int {\n\tvalue := 7\n\treturn value\n}", "format": "json"}) require.False(t, ack.IsError, resultText(ack)) fields := extractTextResult(t, ack) require.Equal(t, "local", fields["ack_mode"]) @@ -99,7 +109,7 @@ func TestRegisteredPreselectionRetirementKeepsCurrentFileRows(t *testing.T) { args["detail"] = "full" } } - result := guardE2ECall(t, s, ctx, 11+i, name, args) + result := preselectionRegisteredCall(t, s, ctx, 11+i, name, args) require.False(t, result.IsError, resultText(result)) } retirement := s.canonicalProjectionRetirement(path) @@ -115,7 +125,7 @@ func TestRegisteredPreselectionRetirementKeepsCurrentFileRows(t *testing.T) { deadline := time.Now().Add(projectedReadBudget) ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, deadline) backend.armed.Store(true) - result := guardE2ECall(t, s, ctx, 20, tool, map[string]any{"path": "main.go", "format": "json"}) + result := preselectionRegisteredCall(t, s, ctx, 20, tool, map[string]any{"path": "main.go", "format": "json"}) require.Equal(t, int32(1), backend.reads.Load(), "actual preselection metadata seam must be reached") t.Logf("registered tool=%s result=%s remaining_original_budget=%s", tool, resultText(result), time.Until(deadline)) requireCanonicalDefinitionRows(t, tool, result, raw) From ae0eae270b2f30f37f628ad63e791c2b286af088 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 18:45:02 +0100 Subject: [PATCH 49/61] test(mcp): bind universal and transport session identities together --- internal/mcp/registered_preselection_retirement_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/mcp/registered_preselection_retirement_test.go b/internal/mcp/registered_preselection_retirement_test.go index 266590954..e8a59ee20 100644 --- a/internal/mcp/registered_preselection_retirement_test.go +++ b/internal/mcp/registered_preselection_retirement_test.go @@ -82,9 +82,10 @@ func TestRegisteredPreselectionRetirementKeepsCurrentFileRows(t *testing.T) { session := "registered-preselection-" + tool require.NoError(t, s.ConnectSession(session, func([]byte) error { return nil })) t.Cleanup(func() { s.DisconnectSession(session) }) - initializeSession(t, s, session) + ctx := s.WithClientSession(WithSessionID(t.Context(), session), session) + require.Equal(t, session, SessionIDFromContext(ctx)) + require.NotNil(t, s.MCPServer().HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"registered-readback","version":"1"}}}`))) s.NoteSessionToolPolicy(session, "all", "defer") - ctx := s.WithClientSession(t.Context(), session) ack := preselectionRegisteredCall(t, s, ctx, 10, "edit_file", map[string]any{"path": path, "old_string": "func Target() int { return 1 }", "new_string": "func Target() int {\n\tvalue := 7\n\treturn value\n}", "format": "json"}) require.False(t, ack.IsError, resultText(ack)) fields := extractTextResult(t, ack) From dc37be9ffb6d36e8b0f1780fa59839fb586eb02f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 18:58:25 +0100 Subject: [PATCH 50/61] test(mcp): race same-session reads over one suspect ACK owner --- .../mcp/registered_same_owner_cow_test.go | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 internal/mcp/registered_same_owner_cow_test.go diff --git a/internal/mcp/registered_same_owner_cow_test.go b/internal/mcp/registered_same_owner_cow_test.go new file mode 100644 index 000000000..e2e1c6385 --- /dev/null +++ b/internal/mcp/registered_same_owner_cow_test.go @@ -0,0 +1,158 @@ +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/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/query" + "github.com/zzet/gortex/internal/tokens" + "go.uber.org/zap" +) + +type sameOwnerReadCOWStore struct { + *store_sqlite.Store + armed atomic.Bool + arrivals atomic.Int32 + captured chan *projectionEntry + release chan struct{} + path string +} + +func (s *sameOwnerReadCOWStore) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { + rows, err := s.Store.FileMetasByPaths(prefix, paths) + if err == nil && s.armed.Load() && s.arrivals.Add(1) <= 3 { + s.captured <- liveEntry(pendingFileProjections, s.path) + <-s.release + } + return rows, err +} + +// The actual daemon admits independent Fast requests from one connection. +// All three reads see the same already-observed source event and capture the +// same ACK owner before any same-byte read-COW publication can win its CAS. +func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { + selectedProjectionRegistry(t) + require.True(t, tokens.EncoderReady()) + root := t.TempDir() + path := filepath.Join(root, "main.go") + require.NoError(t, os.WriteFile(path, []byte("package probe\n\nfunc Target() int { return 1 }\n\nfunc Neighbor() int { return 9 }\n"), 0600)) + sql, err := store_sqlite.Open(filepath.Join(t.TempDir(), "cow.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, sql.Close()) }) + backend := &sameOwnerReadCOWStore{Store: sql, captured: make(chan *projectionEntry, 3), release: make(chan struct{}), path: path} + idx := newProjectionTestIndexer(t, backend) + _, err = idx.Index(root) + require.NoError(t, err) + s := NewServer(query.NewEngine(backend), backend, idx, nil, zap.NewNop(), nil) + s.editAckMode = config.EditAckLocal + pending := make(chan indexer.MutationResult) + s.SetWatcher(mutationTestWatcher{done: pending}) + var receipt *mutationReceipt + t.Cleanup(func() { + close(pending) + if receipt != nil { + select { + case <-receipt.done: + case <-time.After(3 * time.Second): + t.Error("owned held ticket did not join") + } + } + }) + const session = "same-owner-read-cow" + require.NoError(t, s.ConnectSession(session, func([]byte) error { return nil })) + t.Cleanup(func() { s.DisconnectSession(session) }) + ctx := s.WithClientSession(WithSessionID(t.Context(), session), session) + require.NotNil(t, s.MCPServer().HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"registered-readback","version":"1"}}}`))) + s.NoteSessionToolPolicy(session, "all", "defer") + const body = "func Target() int {\n\tvalue := 7\n\treturn value\n}" + ack := preselectionRegisteredCall(t, s, ctx, 10, "edit_file", map[string]any{"path": path, "old_string": "func Target() int { return 1 }", "new_string": body, "format": "json"}) + require.False(t, ack.IsError, resultText(ack)) + fields := extractTextResult(t, ack) + require.Equal(t, "local", fields["ack_mode"]) + require.Equal(t, true, fields["readback_exact"]) + value, ok := s.mutationReceipts.Load(fields["reindex_receipt"].(string)) + require.True(t, ok) + receipt = value.(*mutationReceipt) + entry := liveEntry(pendingFileProjections, path) + require.NotNil(t, entry) + require.Positive(t, entry.commitSeq) + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, 5, sql.GetNode("main.go::Neighbor").StartLine, "the independent canonical rows precede this actual local ACK") + oldCtx, err := retainRequestProjection(withRequestProjectionFacts(t.Context()), entry) + require.NoError(t, err) + pendingFileProjections.markSuspect(path) + require.True(t, entry.suspect.Load()) + require.ErrorIs(t, validateRequestProjections(oldCtx), projectionSuperseded) + originalDeadline := time.Now().Add(projectedReadBudget) + ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, originalDeadline) + backend.armed.Store(true) + release := sync.OnceFunc(func() { close(backend.release) }) + type answer struct { + tool string + result *mcplib.CallToolResult + } + answers := make(chan answer, 3) + var workers sync.WaitGroup + t.Cleanup(func() { release(); workers.Wait() }) + for i, tool := range []string{"get_file_summary", "get_editing_context", "get_symbol_source"} { + workers.Add(1) + go func() { + defer workers.Done() + args := map[string]any{"path": "main.go", "format": "json"} + if tool == "get_symbol_source" { + args = map[string]any{"id": "main.go::Target", "context_lines": 0, "compress_bodies": false, "format": "json"} + } + result := preselectionRegisteredCall(t, s, ctx, 20+i, tool, args) + answers <- answer{tool: tool, result: result} + }() + } + for range 3 { + select { + case captured := <-backend.captured: + require.Same(t, entry, captured, "both metadata reads and source must capture the same old pointer before the actual CAS") + case <-time.After(3 * time.Second): + t.Fatal("owned registered reads did not reach the common metadata barrier") + } + } + release() + results := map[string]*mcplib.CallToolResult{} + for range 3 { + select { + case a := <-answers: + results[a.tool] = a.result + case <-time.After(3 * time.Second): + t.Fatal("owned registered read did not join") + } + } + workers.Wait() + winner := liveEntry(pendingFileProjections, path) + require.NotNil(t, winner) + require.NotSame(t, entry, winner) + require.Zero(t, winner.commitSeq) + require.Same(t, entry.layer, winner.layer) + require.True(t, entry.suspect.Load()) + require.ErrorIs(t, validateRequestProjections(oldCtx), projectionSuperseded, "another read cannot revive the old context") + require.Equal(t, 5, sql.GetNode("main.go::Neighbor").StartLine, "no canonical publication or fabricated Done result changed this control") + t.Logf("remaining original whole-call allowance=%s", time.Until(originalDeadline)) + for _, tool := range []string{"get_file_summary", "get_editing_context", "get_symbol_source"} { + t.Logf("actual registered tool=%s result=%s", tool, resultText(results[tool])) + } + source := results["get_symbol_source"] + require.False(t, source.IsError, resultText(source)) + require.Equal(t, body, extractTextResult(t, source)["source"]) + for _, tool := range []string{"get_file_summary", "get_editing_context"} { + t.Run(tool, func(t *testing.T) { requireCanonicalDefinitionRows(t, tool, results[tool], raw) }) + } +} From aa31d03fd44765cee6b90bb5ada78dd59a72bbf0 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 19:10:50 +0100 Subject: [PATCH 51/61] fix(mcp): replay audited file reads after early projection CAS loss --- internal/mcp/projected_reads.go | 13 +++++++++++-- internal/mcp/registered_same_owner_cow_test.go | 10 ++++++++-- internal/mcp/tools_coding.go | 3 +++ internal/mcp/tools_core.go | 3 +++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index 51b8753ac..478bd02f8 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -32,8 +32,17 @@ func auditedSourceProjectionMismatch(ctx context.Context, err error) bool { return false } allowance, audited := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance) - return audited && (allowance.tool == "" || allowance.tool == "get_symbol_source") && - (errors.Is(err, errProjectionExternalWrite) || errors.Is(err, projectionSuperseded)) + if !audited { + return false + } + // A competing read can replace the same suspect ACK owner with a checked + // same-byte COW entry before this request borrows it. All audited file + // reads must restart the whole attempt instead of losing their file layer + // and serving old base rows. No old facts or reader are rebound in place. + if errors.Is(err, projectionSuperseded) { + return true + } + return (allowance.tool == "" || allowance.tool == "get_symbol_source") && errors.Is(err, errProjectionExternalWrite) } // Each request retains only the immutable file layers it actually checked in diff --git a/internal/mcp/registered_same_owner_cow_test.go b/internal/mcp/registered_same_owner_cow_test.go index e2e1c6385..94042af86 100644 --- a/internal/mcp/registered_same_owner_cow_test.go +++ b/internal/mcp/registered_same_owner_cow_test.go @@ -104,9 +104,12 @@ func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { result *mcplib.CallToolResult } answers := make(chan answer, 3) + allowances := make(map[string]*projectedSourceAllowance) var workers sync.WaitGroup t.Cleanup(func() { release(); workers.Wait() }) for i, tool := range []string{"get_file_summary", "get_editing_context", "get_symbol_source"} { + allowances[tool] = &projectedSourceAllowance{tool: tool} + allowance := allowances[tool] workers.Add(1) go func() { defer workers.Done() @@ -114,7 +117,8 @@ func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { if tool == "get_symbol_source" { args = map[string]any{"id": "main.go::Target", "context_lines": 0, "compress_bodies": false, "format": "json"} } - result := preselectionRegisteredCall(t, s, ctx, 20+i, tool, args) + requestCtx := context.WithValue(ctx, projectedSourceAllowanceKey{}, allowance) + result := preselectionRegisteredCall(t, s, requestCtx, 20+i, tool, args) answers <- answer{tool: tool, result: result} }() } @@ -147,7 +151,9 @@ func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { require.Equal(t, 5, sql.GetNode("main.go::Neighbor").StartLine, "no canonical publication or fabricated Done result changed this control") t.Logf("remaining original whole-call allowance=%s", time.Until(originalDeadline)) for _, tool := range []string{"get_file_summary", "get_editing_context", "get_symbol_source"} { - t.Logf("actual registered tool=%s result=%s", tool, resultText(results[tool])) + t.Logf("actual registered tool=%s attempts=%d result=%s", tool, allowances[tool].attempts.Load(), resultText(results[tool])) + require.GreaterOrEqual(t, allowances[tool].attempts.Load(), int32(1)) + require.LessOrEqual(t, allowances[tool].attempts.Load(), int32(2), "all retry paths share the original two-attempt allowance") } source := results["get_symbol_source"] require.False(t, source.IsError, resultText(source)) diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index f8acfa47b..6fd56b60d 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -367,6 +367,9 @@ func (s *Server) handleGetEditingContext(ctx context.Context, req mcp.CallToolRe ctx, err = s.ensureProjected(ctx, []string{fp}) if err != nil { + if errors.Is(err, errProjectedSourceMoved) { + return nil, err + } return mcp.NewToolResultError(err.Error()), nil } diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index adb88988b..7b17c14ee 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -2765,6 +2765,9 @@ func (s *Server) handleGetFileSummary(ctx context.Context, req mcp.CallToolReque ctx, err = s.ensureProjected(ctx, []string{fp}) if err != nil { + if errors.Is(err, errProjectedSourceMoved) { + return nil, err + } return mcp.NewToolResultError(err.Error()), nil } From d4a7778715ba04622976af77bb291b2bbfbcf81b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 19:07:50 +0100 Subject: [PATCH 52/61] test(mcp): check remaining readers after a same-owner COW loss --- .../registered_remaining_reader_cow_test.go | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 internal/mcp/registered_remaining_reader_cow_test.go diff --git a/internal/mcp/registered_remaining_reader_cow_test.go b/internal/mcp/registered_remaining_reader_cow_test.go new file mode 100644 index 000000000..8fd584f48 --- /dev/null +++ b/internal/mcp/registered_remaining_reader_cow_test.go @@ -0,0 +1,190 @@ +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/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/query" + "github.com/zzet/gortex/internal/tokens" + "go.uber.org/zap" +) + +type remainingReaderCOWStore struct { + *store_sqlite.Store + armed atomic.Bool + arrivals atomic.Int32 + captured chan *projectionEntry + release [2]chan struct{} + path string +} + +func (s *remainingReaderCOWStore) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { + rows, err := s.Store.FileMetasByPaths(prefix, paths) + ordinal := int32(0) + if err == nil && s.armed.Load() { + ordinal = s.arrivals.Add(1) + } + if ordinal > 0 && ordinal <= 2 { + s.captured <- liveEntry(pendingFileProjections, s.path) + <-s.release[ordinal-1] + } + return rows, err +} + +// The actual daemon admits independent Fast requests from one connection. +// Both reads see the same already-observed source event and capture the +// same ACK owner before any same-byte read-COW publication can win its CAS. +func TestRemainingRegisteredReadersKeepCurrentRangesOnCOWLoss(t *testing.T) { + for _, tool := range []string{"get_symbol", "get_cfg", "read_file"} { + t.Run(tool, func(t *testing.T) { + selectedProjectionRegistry(t) + require.True(t, tokens.EncoderReady()) + root := t.TempDir() + path := filepath.Join(root, "main.go") + require.NoError(t, os.WriteFile(path, []byte("package probe\n\nfunc Target() int { return 1 }\n\nfunc Neighbor() int { return 9 }\n"), 0600)) + sql, err := store_sqlite.Open(filepath.Join(t.TempDir(), "cow.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, sql.Close()) }) + backend := &remainingReaderCOWStore{Store: sql, captured: make(chan *projectionEntry, 2), release: [2]chan struct{}{make(chan struct{}), make(chan struct{})}, path: path} + idx := newProjectionTestIndexer(t, backend) + _, err = idx.Index(root) + require.NoError(t, err) + s := NewServer(query.NewEngine(backend), backend, idx, nil, zap.NewNop(), nil) + s.editAckMode = config.EditAckLocal + pending := make(chan indexer.MutationResult) + s.SetWatcher(mutationTestWatcher{done: pending}) + var receipt *mutationReceipt + t.Cleanup(func() { + close(pending) + if receipt != nil { + select { + case <-receipt.done: + case <-time.After(3 * time.Second): + t.Error("owned held ticket did not join") + } + } + }) + const session = "same-owner-read-cow" + require.NoError(t, s.ConnectSession(session, func([]byte) error { return nil })) + t.Cleanup(func() { s.DisconnectSession(session) }) + ctx := s.WithClientSession(WithSessionID(t.Context(), session), session) + require.NotNil(t, s.MCPServer().HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"registered-readback","version":"1"}}}`))) + s.NoteSessionToolPolicy(session, "all", "defer") + const body = "func Target() int {\n\tvalue := 7\n\treturn value\n}" + ack := preselectionRegisteredCall(t, s, ctx, 10, "edit_file", map[string]any{"path": path, "old_string": "func Target() int { return 1 }", "new_string": body, "format": "json"}) + require.False(t, ack.IsError, resultText(ack)) + fields := extractTextResult(t, ack) + require.Equal(t, "local", fields["ack_mode"]) + require.Equal(t, true, fields["readback_exact"]) + value, ok := s.mutationReceipts.Load(fields["reindex_receipt"].(string)) + require.True(t, ok) + receipt = value.(*mutationReceipt) + entry := liveEntry(pendingFileProjections, path) + require.NotNil(t, entry) + require.Positive(t, entry.commitSeq) + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, 5, sql.GetNode("main.go::Neighbor").StartLine, "the independent canonical rows precede this actual local ACK") + oldCtx, err := retainRequestProjection(withRequestProjectionFacts(t.Context()), entry) + require.NoError(t, err) + pendingFileProjections.markSuspect(path) + require.True(t, entry.suspect.Load()) + require.ErrorIs(t, validateRequestProjections(oldCtx), projectionSuperseded) + originalDeadline := time.Now().Add(projectedReadBudget) + ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, originalDeadline) + backend.armed.Store(true) + + releaseFirst := sync.OnceFunc(func() { close(backend.release[0]) }) + releaseSecond := sync.OnceFunc(func() { close(backend.release[1]) }) + release := func() { releaseFirst(); releaseSecond() } + type answer struct { + tool string + result *mcplib.CallToolResult + } + answers := make(chan answer, 2) + var workers sync.WaitGroup + t.Cleanup(func() { release(); workers.Wait() }) + start := func(id int, name string, args map[string]any) { + workers.Add(1) + go func() { + defer workers.Done() + answers <- answer{name, preselectionRegisteredCall(t, s, ctx, id, name, args)} + }() + } + capture := func() { + select { + case captured := <-backend.captured: + require.Same(t, entry, captured) + case <-time.After(3 * time.Second): + t.Fatal("registered read did not reach actual pre-CAS metadata barrier") + } + } + start(20, "get_symbol_source", map[string]any{"id": "main.go::Target", "context_lines": 0, "format": "json"}) + capture() + args := map[string]any{"id": "main.go::Neighbor", "detail": "full", "format": "json"} + if tool == "read_file" { + args = map[string]any{"path": "main.go", "compress_bodies": true, "keep": "Target", "format": "json"} + } + start(21, tool, args) + capture() + releaseFirst() + var first answer + select { + case first = <-answers: + case <-time.After(3 * time.Second): + t.Fatal("winning source did not join") + } + require.Equal(t, "get_symbol_source", first.tool) + require.False(t, first.result.IsError, resultText(first.result)) + require.Equal(t, body, extractTextResult(t, first.result)["source"]) + releaseSecond() + // Both releases are already closed; cleanup must not close them twice. + release = func() {} + var candidate answer + select { + case candidate = <-answers: + case <-time.After(3 * time.Second): + t.Fatal("losing reader did not join") + } + require.Equal(t, tool, candidate.tool) + workers.Wait() + winner := liveEntry(pendingFileProjections, path) + require.NotNil(t, winner) + require.NotSame(t, entry, winner) + require.Zero(t, winner.commitSeq) + require.Same(t, entry.layer, winner.layer) + require.True(t, entry.suspect.Load()) + require.ErrorIs(t, validateRequestProjections(oldCtx), projectionSuperseded, "another read cannot revive the old context") + require.Equal(t, 5, sql.GetNode("main.go::Neighbor").StartLine, "no canonical publication or fabricated Done result changed this control") + t.Logf("remaining original whole-call allowance=%s", time.Until(originalDeadline)) + + t.Logf("actual COW loser tool=%s result=%s", tool, resultText(candidate.result)) + switch tool { + case "get_symbol": + require.False(t, candidate.result.IsError, resultText(candidate.result)) + node := extractTextResult(t, candidate.result)["node"].(map[string]any) + require.EqualValues(t, 8, node["start_line"], "full symbol metadata must not return canonical old5 after losing the actual CAS") + require.EqualValues(t, 8, node["end_line"]) + case "get_cfg": + require.True(t, candidate.result.IsError, "deferred source must refuse instead of building CFG from old ranges") + require.Contains(t, resultText(candidate.result), "source is pending") + case "read_file": + require.False(t, candidate.result.IsError, resultText(candidate.result)) + decoded := extractTextResult(t, candidate.result) + require.Equal(t, "pending", decoded["compression_state"]) + require.Equal(t, string(raw), decoded["content"], "pending keep-range proof must retain actual current raw bytes") + } + }) + } +} From ad8588db53dbfea16e5f525abd4f82eddd9afdf7 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 19:18:21 +0100 Subject: [PATCH 53/61] fix(mcp): retry full metadata CAS selection before response effects --- internal/mcp/projected_source_retry.go | 5 ++- .../mcp/registered_same_owner_cow_test.go | 33 +++++++++++++++++++ internal/mcp/tools_core.go | 3 ++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/internal/mcp/projected_source_retry.go b/internal/mcp/projected_source_retry.go index 1fb663b60..0416d1ee7 100644 --- a/internal/mcp/projected_source_retry.go +++ b/internal/mcp/projected_source_retry.go @@ -59,7 +59,10 @@ func auditedProjectionReadRetry(tool string, req mcplib.CallToolRequest) bool { func (s *Server) withProjectedSourceReadBudget(next mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { legacy, known := s.legacyToolName(&req) - if !known || !auditedProjectionReadRetry(legacy, req) { + // Full symbol metadata may restart only its early projection selection, + // before recordSymbol/enrichNodeOnDemand. Keep it out of the audited + // after-leaf replay set, which would include those response side effects. + if !known || !auditedProjectionReadRetry(legacy, req) && legacy != "get_symbol" { return next(ctx, req) } if _, exists := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance); !exists { diff --git a/internal/mcp/registered_same_owner_cow_test.go b/internal/mcp/registered_same_owner_cow_test.go index 94042af86..d41cffa3f 100644 --- a/internal/mcp/registered_same_owner_cow_test.go +++ b/internal/mcp/registered_same_owner_cow_test.go @@ -162,3 +162,36 @@ func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { t.Run(tool, func(t *testing.T) { requireCanonicalDefinitionRows(t, tool, results[tool], raw) }) } } + +// The newly enabled full-metadata allowance stops at early selection errors. +// A later withdrawal must not replay recordSymbol or on-demand enrichment. +func TestFullSymbolCASAllowanceDoesNotGrantAfterLeafReplay(t *testing.T) { + selectedProjectionRegistry(t) + s, path, entry, _ := acknowledgedCanonicalFile(t) + retirement := s.canonicalProjectionRetirement(path) + require.NotNil(t, retirement) + calls := 0 + var selected context.Context + leaf := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + calls++ + selected = ctx + result, err := s.handleGetSymbol(ctx, req) + require.NoError(t, err) + require.False(t, result.IsError, resultText(result)) + require.EqualValues(t, 8, extractTextResult(t, result)["node"].(map[string]any)["start_line"]) + require.NoError(t, s.indexer.IndexFile(path)) + retirement.run() + require.True(t, entry.checkedRetired.Load()) + return result, nil + } + result, err := s.wrapToolHandler(leaf)(t.Context(), makeReq("get_symbol", map[string]any{"id": "main.go::Neighbor", "detail": "full", "format": "json"})) + require.NoError(t, err) + require.True(t, result.IsError, resultText(result)) + require.Equal(t, 1, calls) + require.Error(t, validateRequestProjections(selected), "a late old selection remains unusable") + facts := selected.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + facts.mu.RLock() + old := facts.entries[entry.key] + facts.mu.RUnlock() + require.Same(t, entry, old) +} diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index 7b17c14ee..77079f1a8 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -1727,6 +1727,9 @@ func (s *Server) handleGetSymbol(ctx context.Context, req mcp.CallToolRequest) ( if parts := strings.SplitN(id, "::", 2); len(parts) == 2 { ctx, err = s.ensureProjected(ctx, []string{parts[0]}) if err != nil { + if errors.Is(err, errProjectedSourceMoved) { + return nil, err + } return mcp.NewToolResultError(err.Error()), nil } } From 4a2dfeb0f1e2d2524e996b3c750fd335f3bb85c6 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 20:04:52 +0100 Subject: [PATCH 54/61] test: exercise native concurrent read population --- .../mcp/registered_same_owner_cow_test.go | 77 +++++++++++++++---- 1 file changed, 62 insertions(+), 15 deletions(-) diff --git a/internal/mcp/registered_same_owner_cow_test.go b/internal/mcp/registered_same_owner_cow_test.go index d41cffa3f..acd49d210 100644 --- a/internal/mcp/registered_same_owner_cow_test.go +++ b/internal/mcp/registered_same_owner_cow_test.go @@ -22,16 +22,17 @@ import ( type sameOwnerReadCOWStore struct { *store_sqlite.Store - armed atomic.Bool - arrivals atomic.Int32 - captured chan *projectionEntry - release chan struct{} - path string + armed atomic.Bool + arrivals atomic.Int32 + gateReads int32 + captured chan *projectionEntry + release chan struct{} + path string } func (s *sameOwnerReadCOWStore) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { rows, err := s.Store.FileMetasByPaths(prefix, paths) - if err == nil && s.armed.Load() && s.arrivals.Add(1) <= 3 { + if err == nil && s.armed.Load() && s.arrivals.Add(1) <= s.gateReads { s.captured <- liveEntry(pendingFileProjections, s.path) <-s.release } @@ -42,6 +43,23 @@ func (s *sameOwnerReadCOWStore) FileMetasByPaths(prefix string, paths []string) // All three reads see the same already-observed source event and capture the // same ACK owner before any same-byte read-COW publication can win its CAS. func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { + runConcurrentRegisteredReadPopulation(t, false) +} + +// Match the native connection's full public population while keeping canonical +// publication held. This distinguishes read-COW contention from a later real +// mutation, source event or checked canonical retirement. +func TestRegisteredConcurrentNativeReadPopulationKeepsCurrentRows(t *testing.T) { + runConcurrentRegisteredReadPopulation(t, true) +} + +func runConcurrentRegisteredReadPopulation(t *testing.T, nativePopulation bool) { + tools := []string{"get_file_summary", "get_editing_context", "get_symbol_source"} + gateReads := int32(3) + if nativePopulation { + tools = append(tools, "get_symbol", "read_file") + gateReads = 4 // raw read_file does not need graph projection metadata + } selectedProjectionRegistry(t) require.True(t, tokens.EncoderReady()) root := t.TempDir() @@ -50,7 +68,7 @@ func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { sql, err := store_sqlite.Open(filepath.Join(t.TempDir(), "cow.sqlite")) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sql.Close()) }) - backend := &sameOwnerReadCOWStore{Store: sql, captured: make(chan *projectionEntry, 3), release: make(chan struct{}), path: path} + backend := &sameOwnerReadCOWStore{Store: sql, captured: make(chan *projectionEntry, gateReads), release: make(chan struct{}), path: path, gateReads: gateReads} idx := newProjectionTestIndexer(t, backend) _, err = idx.Index(root) require.NoError(t, err) @@ -76,7 +94,13 @@ func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { require.NotNil(t, s.MCPServer().HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"registered-readback","version":"1"}}}`))) s.NoteSessionToolPolicy(session, "all", "defer") const body = "func Target() int {\n\tvalue := 7\n\treturn value\n}" - ack := preselectionRegisteredCall(t, s, ctx, 10, "edit_file", map[string]any{"path": path, "old_string": "func Target() int { return 1 }", "new_string": body, "format": "json"}) + editTool := "edit_file" + editArgs := map[string]any{"path": path, "old_string": "func Target() int { return 1 }", "new_string": body, "format": "json"} + if nativePopulation { + editTool = "edit_symbol" + editArgs = map[string]any{"id": "main.go::Target", "old_source": "func Target() int { return 1 }", "new_source": body, "dry_run": false} + } + ack := preselectionRegisteredCall(t, s, ctx, 10, editTool, editArgs) require.False(t, ack.IsError, resultText(ack)) fields := extractTextResult(t, ack) require.Equal(t, "local", fields["ack_mode"]) @@ -103,12 +127,14 @@ func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { tool string result *mcplib.CallToolResult } - answers := make(chan answer, 3) + answers := make(chan answer, len(tools)) allowances := make(map[string]*projectedSourceAllowance) var workers sync.WaitGroup t.Cleanup(func() { release(); workers.Wait() }) - for i, tool := range []string{"get_file_summary", "get_editing_context", "get_symbol_source"} { - allowances[tool] = &projectedSourceAllowance{tool: tool} + for i, tool := range tools { + if tool != "read_file" { + allowances[tool] = &projectedSourceAllowance{tool: tool} + } allowance := allowances[tool] workers.Add(1) go func() { @@ -117,12 +143,18 @@ func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { if tool == "get_symbol_source" { args = map[string]any{"id": "main.go::Target", "context_lines": 0, "compress_bodies": false, "format": "json"} } - requestCtx := context.WithValue(ctx, projectedSourceAllowanceKey{}, allowance) + if tool == "get_symbol" { + args = map[string]any{"id": "main.go::Neighbor", "detail": "full", "format": "json"} + } + requestCtx := ctx + if allowance != nil { + requestCtx = context.WithValue(ctx, projectedSourceAllowanceKey{}, allowance) + } result := preselectionRegisteredCall(t, s, requestCtx, 20+i, tool, args) answers <- answer{tool: tool, result: result} }() } - for range 3 { + for range gateReads { select { case captured := <-backend.captured: require.Same(t, entry, captured, "both metadata reads and source must capture the same old pointer before the actual CAS") @@ -132,7 +164,7 @@ func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { } release() results := map[string]*mcplib.CallToolResult{} - for range 3 { + for range tools { select { case a := <-answers: results[a.tool] = a.result @@ -150,11 +182,26 @@ func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { require.ErrorIs(t, validateRequestProjections(oldCtx), projectionSuperseded, "another read cannot revive the old context") require.Equal(t, 5, sql.GetNode("main.go::Neighbor").StartLine, "no canonical publication or fabricated Done result changed this control") t.Logf("remaining original whole-call allowance=%s", time.Until(originalDeadline)) - for _, tool := range []string{"get_file_summary", "get_editing_context", "get_symbol_source"} { + for _, tool := range tools { + if tool == "read_file" { + continue + } t.Logf("actual registered tool=%s attempts=%d result=%s", tool, allowances[tool].attempts.Load(), resultText(results[tool])) require.GreaterOrEqual(t, allowances[tool].attempts.Load(), int32(1)) require.LessOrEqual(t, allowances[tool].attempts.Load(), int32(2), "all retry paths share the original two-attempt allowance") } + if nativePopulation { + metadata := results["get_symbol"] + require.False(t, metadata.IsError, resultText(metadata)) + node := extractTextResult(t, metadata)["node"].(map[string]any) + require.Equal(t, "main.go::Neighbor", node["id"]) + require.EqualValues(t, 8, node["start_line"], "full metadata must carry the actual shifted neighbor") + require.EqualValues(t, 8, node["end_line"]) + file := results["read_file"] + require.False(t, file.IsError, resultText(file)) + require.Equal(t, string(raw), extractTextResult(t, file)["content"]) + require.EqualValues(t, len(raw), extractTextResult(t, file)["bytes"]) + } source := results["get_symbol_source"] require.False(t, source.IsError, resultText(source)) require.Equal(t, body, extractTextResult(t, source)["source"]) From 7bca656a0532322858b9f29f628d9e855d6015a6 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 20:12:40 +0100 Subject: [PATCH 55/61] test: isolate post-borrow same-byte read COW refusal --- .../mcp/registered_same_owner_cow_test.go | 126 +++++++++++++++--- 1 file changed, 109 insertions(+), 17 deletions(-) diff --git a/internal/mcp/registered_same_owner_cow_test.go b/internal/mcp/registered_same_owner_cow_test.go index acd49d210..e011b418d 100644 --- a/internal/mcp/registered_same_owner_cow_test.go +++ b/internal/mcp/registered_same_owner_cow_test.go @@ -43,17 +43,31 @@ func (s *sameOwnerReadCOWStore) FileMetasByPaths(prefix string, paths []string) // All three reads see the same already-observed source event and capture the // same ACK owner before any same-byte read-COW publication can win its CAS. func TestConcurrentRegisteredReadsRevalidateSameACKOwner(t *testing.T) { - runConcurrentRegisteredReadPopulation(t, false) + runConcurrentRegisteredReadPopulation(t, false, false) } // Match the native connection's full public population while keeping canonical // publication held. This distinguishes read-COW contention from a later real // mutation, source event or checked canonical retirement. func TestRegisteredConcurrentNativeReadPopulationKeepsCurrentRows(t *testing.T) { - runConcurrentRegisteredReadPopulation(t, true) + runConcurrentRegisteredReadPopulation(t, true, false) } -func runConcurrentRegisteredReadPopulation(t *testing.T, nativePopulation bool) { +// A second public read may verify the unchanged source and COW the read-only +// owner while another exact read is assembling its response. The original +// context remains invalid; audited readers must use only their remaining whole +// call allowance, while full metadata still forbids late enrichment replay. +func TestRegisteredReadCOWAfterBorrowUsesOriginalFreshAllowance(t *testing.T) { + runConcurrentRegisteredReadPopulation(t, true, true) +} + +type registeredReadCOWActorKey struct{} +type registeredBorrowObservation struct { + tool string + ctx context.Context +} + +func runConcurrentRegisteredReadPopulation(t *testing.T, nativePopulation, afterBorrow bool) { tools := []string{"get_file_summary", "get_editing_context", "get_symbol_source"} gateReads := int32(3) if nativePopulation { @@ -74,6 +88,30 @@ func runConcurrentRegisteredReadPopulation(t *testing.T, nativePopulation bool) require.NoError(t, err) s := NewServer(query.NewEngine(backend), backend, idx, nil, zap.NewNop(), nil) s.editAckMode = config.EditAckLocal + borrowed := make(chan registeredBorrowObservation, 8) + releaseBorrowed := make(chan struct{}) + releaseBorrowedOnce := sync.OnceFunc(func() { close(releaseBorrowed) }) + t.Cleanup(releaseBorrowedOnce) + if afterBorrow { + for _, name := range tools { + if name == "read_file" { + continue + } + s.facades.mu.RLock() + captured := s.facades.captured[name] + s.facades.mu.RUnlock() + leaf := captured.handler + tool := name + s.addTool(captured.tool, func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + result, err := leaf(ctx, req) + if err == nil && result != nil && !result.IsError && ctx.Value(registeredReadCOWActorKey{}) != true { + borrowed <- registeredBorrowObservation{tool: tool, ctx: ctx} + <-releaseBorrowed + } + return result, err + }) + } + } pending := make(chan indexer.MutationResult) s.SetWatcher(mutationTestWatcher{done: pending}) var receipt *mutationReceipt @@ -119,9 +157,19 @@ func runConcurrentRegisteredReadPopulation(t *testing.T, nativePopulation bool) pendingFileProjections.markSuspect(path) require.True(t, entry.suspect.Load()) require.ErrorIs(t, validateRequestProjections(oldCtx), projectionSuperseded) + var borrowedOwner *projectionEntry + if afterBorrow { + actor := context.WithValue(ctx, registeredReadCOWActorKey{}, true) + refreshed := preselectionRegisteredCall(t, s, actor, 15, "get_symbol_source", map[string]any{"id": "main.go::Target", "context_lines": 0, "compress_bodies": false, "format": "json"}) + require.False(t, refreshed.IsError, resultText(refreshed)) + borrowedOwner = liveEntry(pendingFileProjections, path) + require.NotSame(t, entry, borrowedOwner) + require.Zero(t, borrowedOwner.commitSeq) + require.Same(t, entry.layer, borrowedOwner.layer) + } originalDeadline := time.Now().Add(projectedReadBudget) ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, originalDeadline) - backend.armed.Store(true) + backend.armed.Store(!afterBorrow) release := sync.OnceFunc(func() { close(backend.release) }) type answer struct { tool string @@ -130,7 +178,7 @@ func runConcurrentRegisteredReadPopulation(t *testing.T, nativePopulation bool) answers := make(chan answer, len(tools)) allowances := make(map[string]*projectedSourceAllowance) var workers sync.WaitGroup - t.Cleanup(func() { release(); workers.Wait() }) + t.Cleanup(func() { release(); releaseBorrowedOnce(); workers.Wait() }) for i, tool := range tools { if tool != "read_file" { allowances[tool] = &projectedSourceAllowance{tool: tool} @@ -154,15 +202,54 @@ func runConcurrentRegisteredReadPopulation(t *testing.T, nativePopulation bool) answers <- answer{tool: tool, result: result} }() } - for range gateReads { - select { - case captured := <-backend.captured: - require.Same(t, entry, captured, "both metadata reads and source must capture the same old pointer before the actual CAS") - case <-time.After(3 * time.Second): - t.Fatal("owned registered reads did not reach the common metadata barrier") + if !afterBorrow { + for range gateReads { + select { + case captured := <-backend.captured: + require.Same(t, entry, captured, "both metadata reads and source must capture the same old pointer before the actual CAS") + case <-time.After(3 * time.Second): + t.Fatal("owned registered reads did not reach the common metadata barrier") + } + } + release() + } else { + selected := make(map[string]context.Context) + for range 4 { + select { + case observation := <-borrowed: + require.NotContains(t, selected, observation.tool) + selected[observation.tool] = observation.ctx + facts := observation.ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts) + facts.mu.RLock() + owned := facts.entries[entry.key] + facts.mu.RUnlock() + require.Same(t, borrowedOwner, owned) + case <-time.After(3 * time.Second): + t.Fatal("four actual public leaves did not borrow the same current read owner") + } + } + pendingFileProjections.markSuspect(path) + require.True(t, borrowedOwner.suspect.Load()) + for _, old := range selected { + require.ErrorIs(t, validateRequestProjections(old), projectionSuperseded) } + actor := context.WithValue(ctx, registeredReadCOWActorKey{}, true) + refreshed := preselectionRegisteredCall(t, s, actor, 40, "get_symbol_source", map[string]any{"id": "main.go::Target", "context_lines": 0, "compress_bodies": false, "format": "json"}) + require.False(t, refreshed.IsError, resultText(refreshed)) + current := liveEntry(pendingFileProjections, path) + require.NotSame(t, borrowedOwner, current) + require.Zero(t, current.commitSeq) + require.Same(t, borrowedOwner.layer, current.layer) + require.Equal(t, borrowedOwner.rawHash, current.rawHash) + require.False(t, current.suspect.Load()) + pendingFileProjections.mu.Lock() + t.Logf("post-borrow read-COW old_events=%d current_events=%d old_checked=%v current_checked=%v", borrowedOwner.sourceEvents, current.sourceEvents, borrowedOwner.checkedRetired.Load(), current.checkedRetired.Load()) + pendingFileProjections.mu.Unlock() + for _, old := range selected { + require.ErrorIs(t, validateRequestProjections(old), projectionSuperseded, "the new response cannot revive the original context") + } + releaseBorrowedOnce() } - release() results := map[string]*mcplib.CallToolResult{} for range tools { select { @@ -192,11 +279,16 @@ func runConcurrentRegisteredReadPopulation(t *testing.T, nativePopulation bool) } if nativePopulation { metadata := results["get_symbol"] - require.False(t, metadata.IsError, resultText(metadata)) - node := extractTextResult(t, metadata)["node"].(map[string]any) - require.Equal(t, "main.go::Neighbor", node["id"]) - require.EqualValues(t, 8, node["start_line"], "full metadata must carry the actual shifted neighbor") - require.EqualValues(t, 8, node["end_line"]) + if afterBorrow { + require.True(t, metadata.IsError, "full metadata has no late session/LSP replay authority") + require.Equal(t, int32(1), allowances["get_symbol"].attempts.Load()) + } else { + require.False(t, metadata.IsError, resultText(metadata)) + node := extractTextResult(t, metadata)["node"].(map[string]any) + require.Equal(t, "main.go::Neighbor", node["id"]) + require.EqualValues(t, 8, node["start_line"], "full metadata must carry the actual shifted neighbor") + require.EqualValues(t, 8, node["end_line"]) + } file := results["read_file"] require.False(t, file.IsError, resultText(file)) require.Equal(t, string(raw), extractTextResult(t, file)["content"]) From 7fa55d1210787b17e1fd3475c26b9bde73573ba4 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 20:21:05 +0100 Subject: [PATCH 56/61] fix: retry checked same-version read COW with physical fencing --- internal/mcp/overlay.go | 4 +- internal/mcp/pending_projection.go | 55 ++++---- internal/mcp/projected_source_retry.go | 40 ++++-- internal/mcp/read_cow_retry_version_test.go | 141 ++++++++++++++++++++ 4 files changed, 204 insertions(+), 36 deletions(-) create mode 100644 internal/mcp/read_cow_retry_version_test.go diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index 05021f3e5..34b678ef0 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -445,7 +445,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 auditedProjectionRead && res != nil && !res.IsError && retrySourceAfterCheckedRetirement(ctx) { + if auditedProjectionRead && res != nil && !res.IsError && retryAfterCheckedProjectionChange(ctx) { return nil, errProjectedSourceMoved } res = s.decorateToolFreshness(ctx, req.Params.Name, req, res) @@ -460,7 +460,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // only after the last ownership check, including decorator-time I/O. if auditedProjectionRead && hErr == nil && res != nil && !res.IsError { if err := validateRequestProjections(ctx); err != nil { - if retrySourceAfterCheckedRetirement(ctx) { + if retryAfterCheckedProjectionChange(ctx) { return nil, errProjectedSourceMoved } res = mcp.NewToolResultError(err.Error()) diff --git a/internal/mcp/pending_projection.go b/internal/mcp/pending_projection.go index 95454da2d..968aeaa20 100644 --- a/internal/mcp/pending_projection.go +++ b/internal/mcp/pending_projection.go @@ -66,6 +66,9 @@ type projectionEntry struct { graphPath string repoPrefix string commitSeq uint64 + // Existing physical source version survives a checked read-only COW copy. + // A new physical execution supplies its own sequence; zero stays unavailable. + sourceCommitSeq 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 @@ -175,20 +178,21 @@ func newProjectionEntry(absPath, repoPrefix string, projection *indexer.Projecti 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 + 16, + key: pathkey.MutationKey(absPath), + absPath: absPath, + graphPath: projection.GraphPath, + repoPrefix: repoPrefix, + commitSeq: commitSeq, + sourceCommitSeq: 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 + 24, } } @@ -196,9 +200,13 @@ func newProjectionEntry(absPath, repoPrefix string, projection *indexer.Projecti // 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 { + sourceSequence := e.sourceCommitSeq + if sequence != 0 { + sourceSequence = sequence + } 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, + commitSeq: sequence, sourceCommitSeq: sourceSequence, 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, } @@ -214,14 +222,15 @@ func newProjectionTombstone(absPath, graphPath, repoPrefix string, commitSeq uin layer := graph.NewOverlayLayer() layer.MarkFile(graphPath, true) return &projectionEntry{ - key: pathkey.MutationKey(absPath), - absPath: absPath, - graphPath: graphPath, - repoPrefix: repoPrefix, - commitSeq: commitSeq, - layer: layer, - size: 16, // charge the source-event revision and checked-retirement hint - tombstone: true, + key: pathkey.MutationKey(absPath), + absPath: absPath, + graphPath: graphPath, + repoPrefix: repoPrefix, + commitSeq: commitSeq, + sourceCommitSeq: commitSeq, + layer: layer, + size: 24, // source version, source-event revision and checked-retirement hint + tombstone: true, } } diff --git a/internal/mcp/projected_source_retry.go b/internal/mcp/projected_source_retry.go index 0416d1ee7..b4d0fd792 100644 --- a/internal/mcp/projected_source_retry.go +++ b/internal/mcp/projected_source_retry.go @@ -89,13 +89,15 @@ func (a *projectedSourceAllowance) claim() bool { } } -// Permission to revalidate from scratch, not freshness authority for an old -// response. Peer owners, invalidation and unchecked eviction remain refusals. -func retrySourceAfterCheckedRetirement(ctx context.Context) bool { +// Permission for a fresh bounded attempt, never freshness of the old context. +// Checked canonical retirement and a checked same-version read-COW are distinct +// from another physical execution, content change or an unobserved source event. +func retryAfterCheckedProjectionChange(ctx context.Context) bool { if ctx.Err() != nil { return false } - if _, ok := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance); !ok { + allowance, ok := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance) + if !ok || allowance.tool == "get_symbol" { return false } deadline, ok := ctx.Value(projectedSourceDeadlineKey{}).(time.Time) @@ -108,24 +110,40 @@ func retrySourceAfterCheckedRetirement(ctx context.Context) bool { } facts.mu.RLock() defer facts.mu.RUnlock() - retired := false + changed := false for _, entry := range facts.entries { - if entry == nil { + if entry == nil || ctx.Err() != nil || !time.Now().Before(deadline) { return false } + candidate := pendingFileProjections.snap.Load().entries[entry.key] + // Stat remains outside the registry mutex. Under the mutex, bind the + // observed disk check to the exact current pointer/event revision. + live := candidate != nil && liveProjectionForAcknowledgement(candidate) + sameDisk := candidate != nil && candidate.disk.matches(entry.disk) + pendingFileProjections.mu.Lock() current := pendingFileProjections.snap.Load().entries[entry.key] - if current == entry && liveProjectionForAcknowledgement(entry) { + unchanged := current == entry && current == candidate && live + retired := current == nil && entry.checkedRetired.Load() && !entry.suspect.Load() + readCOW := current != nil && current != entry && current == candidate && live && + current.commitSeq == 0 && current.sourceCommitSeq != 0 && current.sourceCommitSeq == entry.sourceCommitSeq && + !current.suspect.Load() && current.sourceEvents == 0 && !current.checkedRetired.Load() && + !current.tombstone && !entry.tombstone && current.layer != nil && current.layer == entry.layer && + current.key == entry.key && current.graphPath == entry.graphPath && current.repoPrefix == entry.repoPrefix && + current.hash == entry.hash && current.rawHash == entry.rawHash && current.contentSHA256 == entry.contentSHA256 && + sameDisk + pendingFileProjections.mu.Unlock() + if unchanged { continue } - if current != nil || !entry.checkedRetired.Load() || entry.suspect.Load() { + if !retired && !readCOW { return false } - retired = true + changed = true } - return retired + return changed } -// Audited file reads retry once after checked retirement; source also retains +// Audited file reads retry once after a checked version transition; source retains // its typed byte-mismatch retry. Every attempt has fresh facts/layers/leases // under the original shared deadline and two-attempt allowance. func (s *Server) retryProjectedSymbolSource(attempt mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { diff --git a/internal/mcp/read_cow_retry_version_test.go b/internal/mcp/read_cow_retry_version_test.go new file mode 100644 index 000000000..e07bbc17c --- /dev/null +++ b/internal/mcp/read_cow_retry_version_test.go @@ -0,0 +1,141 @@ +package mcp + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestReadCOWRetryRequiresVerifiedPhysicalSourceVersion(t *testing.T) { + for _, transition := range []string{"verified_read", "new_physical_commit", "later_event", "event_legacy_lookup", "changed_content", "unchecked_removal", "cancelled", "expired", "full_metadata"} { + t.Run(transition, func(t *testing.T) { + selectedProjectionRegistry(t) + s, path, original, raw := acknowledgedCanonicalFile(t) + require.Positive(t, original.sourceCommitSeq) + require.Equal(t, original.commitSeq, original.sourceCommitSeq) + caller, cancel := context.WithCancel(t.Context()) + defer cancel() + deadline := time.Now().Add(projectedReadBudget) + ctx := context.WithValue(caller, projectedSourceDeadlineKey{}, deadline) + tool := "get_symbol_source" + if transition == "full_metadata" { + tool = "get_symbol" + } + allowance := &projectedSourceAllowance{tool: tool} + ctx = context.WithValue(ctx, projectedSourceAllowanceKey{}, allowance) + ctx, err := retainRequestProjection(ctx, original) + require.NoError(t, err) + + if transition == "new_physical_commit" { + held, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + commit, writeErr := s.commitFileMutation(held, "write_file", "", "", "main.go", path, raw, 0600) + release() + require.NoError(t, writeErr) + require.Greater(t, commit.commitSeq, original.commitSeq) + physical := liveEntry(pendingFileProjections, path) + require.Equal(t, commit.commitSeq, physical.sourceCommitSeq) + // The physical bytes really were committed above. Sharing their + // identical immutable parse must not erase that new execution. + shared := original.committedCopy(commit.commitSeq, physical.disk) + require.Same(t, original.layer, shared.layer) + require.Equal(t, original.rawHash, shared.rawHash) + require.NoError(t, pendingFileProjections.publish(shared)) + } + if transition == "changed_content" { + require.NoError(t, os.WriteFile(path, []byte("package probe\n\nfunc Target() int { return 55 }\n\nfunc Neighbor() int { return 9 }\n"), 0600)) + } + pendingFileProjections.markSuspect(path) + build, stop := context.WithDeadline(caller, deadline) + defer stop() + current, err := s.projectCurrentTrackedFile(build, t.Context(), path, "main.go", "main.go") + require.NoError(t, err) + require.NotNil(t, current) + require.Zero(t, current.commitSeq) + require.NotSame(t, original, current) + require.Same(t, current, liveEntry(pendingFileProjections, path)) + if transition == "new_physical_commit" { + require.Same(t, original.layer, current.layer) + require.Equal(t, original.rawHash, current.rawHash) + require.Greater(t, current.sourceCommitSeq, original.sourceCommitSeq) + } else if transition != "changed_content" { + require.Same(t, original.layer, current.layer) + require.Equal(t, original.sourceCommitSeq, current.sourceCommitSeq) + } + switch transition { + case "later_event", "event_legacy_lookup": + pendingFileProjections.markSuspect(path) + if transition == "event_legacy_lookup" { + found, err := pendingFileProjections.lookup(path) + require.NoError(t, err) + require.Same(t, current, found) + require.False(t, current.suspect.Load()) + } + case "unchecked_removal": + pendingFileProjections.drop(current) + require.False(t, original.checkedRetired.Load()) + case "cancelled": + cancel() + case "expired": + ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, time.Now().Add(-time.Nanosecond)) + } + require.Equal(t, transition == "verified_read", retryAfterCheckedProjectionChange(ctx)) + require.Zero(t, allowance.attempts.Load(), "permission itself neither runs nor claims another attempt") + require.Error(t, validateRequestProjections(ctx), "no new proof can revive the original facts") + }) + } +} + +func TestReadCOWSourceVersionCopyAndByteAccounting(t *testing.T) { + selectedProjectionRegistry(t) + s, path, physical, _ := acknowledgedCanonicalFile(t) + charged := physical.size + require.Equal(t, charged, pendingFileProjections.snap.Load().bytes) + pendingFileProjections.markSuspect(path) + read, err := s.projectCurrentTrackedFile(t.Context(), t.Context(), path, "main.go", "main.go") + require.NoError(t, err) + require.Zero(t, read.commitSeq) + require.Equal(t, physical.commitSeq, read.sourceCommitSeq) + require.Equal(t, charged, read.size) + require.Equal(t, charged, pendingFileProjections.snap.Load().bytes, "COW replacement must not double-charge a payload") + peer := read.committedCopy(physical.commitSeq+1, read.disk) + require.Equal(t, physical.commitSeq+1, peer.sourceCommitSeq) + require.Equal(t, charged, peer.size) + nextRead := peer.committedCopy(0, peer.disk) + require.Equal(t, peer.sourceCommitSeq, nextRead.sourceCommitSeq) + require.Zero(t, nextRead.sourceEvents) + require.False(t, nextRead.checkedRetired.Load()) + deleted := newProjectionTombstone(path, "main.go", "", 77) + require.Equal(t, int64(24), deleted.size, "charge source version, source-event revision, and checked-retirement state") + require.Equal(t, uint64(77), deleted.sourceCommitSeq) + require.Equal(t, uint64(77), deleted.committedCopy(0, deleted.disk).sourceCommitSeq) + require.Equal(t, uint64(78), deleted.committedCopy(78, deleted.disk).sourceCommitSeq) +} + +func TestReadCOWWithoutPhysicalSourceVersionCannotGrantRetry(t *testing.T) { + selectedProjectionRegistry(t) + s, _, path := canonicalPromotionReadFixture(t) + raw := []byte("package probe\n\nfunc Target() int { return 7 }\n\nfunc Neighbor() int { return 9 }\n") + require.NoError(t, os.WriteFile(path, raw, 0600)) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + // This is an external read projection, with no original tool execution. + original, err := projectFile(t.Context(), s.indexer, path, raw, 0, stamp) + require.NoError(t, err) + require.NoError(t, pendingFileProjections.publish(original)) + ctx := context.WithValue(t.Context(), projectedSourceDeadlineKey{}, time.Now().Add(projectedReadBudget)) + ctx = context.WithValue(ctx, projectedSourceAllowanceKey{}, &projectedSourceAllowance{tool: "get_symbol_source"}) + ctx, err = retainRequestProjection(ctx, original) + require.NoError(t, err) + pendingFileProjections.markSuspect(path) + current, err := s.projectCurrentTrackedFile(t.Context(), t.Context(), path, "main.go", "main.go") + require.NoError(t, err) + require.NotSame(t, original, current) + require.Same(t, original.layer, current.layer) + require.Zero(t, current.sourceCommitSeq, "unavailable physical provenance must stay unavailable") + require.False(t, retryAfterCheckedProjectionChange(ctx)) + require.ErrorIs(t, validateRequestProjections(ctx), projectionSuperseded) +} From f26cd4ceed0be02fbbd8e4a13c71e80af2fb2194 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 21:08:34 +0100 Subject: [PATCH 57/61] test: preserve retirement proof across an unchanged losing read --- internal/mcp/read_retirement_poison_test.go | 105 ++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 internal/mcp/read_retirement_poison_test.go diff --git a/internal/mcp/read_retirement_poison_test.go b/internal/mcp/read_retirement_poison_test.go new file mode 100644 index 000000000..33648539a --- /dev/null +++ b/internal/mcp/read_retirement_poison_test.go @@ -0,0 +1,105 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "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" + "github.com/zzet/gortex/internal/tokens" +) + +// A metadata read can overlap checked canonical retirement after capturing its +// expected owner. An unchanged read must not call that historical owner a new +// source invalidation merely because it no longer occupies the registry slot. +func TestUnchangedReadAfterRetirementCannotPoisonBorrowedRetryProof(t *testing.T) { + selectedProjectionRegistry(t) + require.True(t, tokens.EncoderReady()) + root := t.TempDir() + path := filepath.Join(root, "main.go") + require.NoError(t, os.WriteFile(path, []byte("package probe\n\nfunc Target() int { return 1 }\n\nfunc Neighbor() int { return 9 }\n"), 0600)) + sql, err := store_sqlite.Open(filepath.Join(t.TempDir(), "retirement.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, sql.Close()) }) + backend := &preselectionRetirementStore{Store: sql} + idx := newProjectionTestIndexer(t, backend) + _, err = idx.Index(root) + require.NoError(t, err) + s := NewServer(query.NewEngine(backend), backend, idx, nil, zap.NewNop(), nil) + s.editAckMode = config.EditAckLocal + pending := make(chan indexer.MutationResult) + s.SetWatcher(mutationTestWatcher{done: pending}) + var receipt *mutationReceipt + t.Cleanup(func() { + close(pending) + if receipt != nil { + select { + case <-receipt.done: + case <-time.After(3 * time.Second): + t.Error("owned held publication ticket did not join") + } + } + }) + const session = "unchanged-read-retirement" + require.NoError(t, s.ConnectSession(session, func([]byte) error { return nil })) + t.Cleanup(func() { s.DisconnectSession(session) }) + ctx := s.WithClientSession(WithSessionID(t.Context(), session), session) + require.NotNil(t, s.MCPServer().HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"registered-readback","version":"1"}}}`))) + s.NoteSessionToolPolicy(session, "all", "defer") + const body = "func Target() int {\n\tvalue := 7\n\treturn value\n}" + ack := preselectionRegisteredCall(t, s, ctx, 10, "edit_symbol", map[string]any{"id": "main.go::Target", "old_source": "func Target() int { return 1 }", "new_source": body, "dry_run": false}) + require.False(t, ack.IsError, resultText(ack)) + fields := extractTextResult(t, ack) + require.Equal(t, "local", fields["ack_mode"]) + require.Equal(t, true, fields["readback_exact"]) + value, ok := s.mutationReceipts.Load(fields["reindex_receipt"].(string)) + require.True(t, ok) + receipt = value.(*mutationReceipt) + entry := liveEntry(pendingFileProjections, path) + require.NotNil(t, entry) + proof, err := retainRequestProjection(withRequestProjectionFacts(t.Context()), entry) + require.NoError(t, err) + raw, err := os.ReadFile(path) + require.NoError(t, err) + retirement := s.canonicalProjectionRetirement(path) + require.NotNil(t, retirement) + backend.publish = func() { + require.Equal(t, 5, sql.GetNode("main.go::Neighbor").StartLine) + require.NoError(t, idx.IndexFile(path)) + require.Equal(t, 8, sql.GetNode("main.go::Neighbor").StartLine) + require.Empty(t, indexer.DiffProjectedNodes(entry.layer.FileNodes("main.go"), sql.GetFileNodes("main.go"))) + retirement.run() + require.True(t, entry.checkedRetired.Load()) + require.False(t, entry.suspect.Load(), "actual checked retirement starts with unchanged source proof") + require.Nil(t, liveEntry(pendingFileProjections, path)) + } + deadline := time.Now().Add(projectedReadBudget) + ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, deadline) + allowance := &projectedSourceAllowance{tool: "get_file_summary"} + ctx = context.WithValue(ctx, projectedSourceAllowanceKey{}, allowance) + proof = context.WithValue(proof, projectedSourceDeadlineKey{}, deadline) + proof = context.WithValue(proof, projectedSourceAllowanceKey{}, &projectedSourceAllowance{tool: "get_symbol_source"}) + backend.armed.Store(true) + result := preselectionRegisteredCall(t, s, ctx, 20, "get_file_summary", map[string]any{"path": "main.go", "format": "json"}) + requireCanonicalDefinitionRows(t, "get_file_summary", result, raw) + require.Equal(t, int32(1), backend.reads.Load()) + require.LessOrEqual(t, allowance.attempts.Load(), int32(2)) + require.True(t, time.Now().Before(deadline)) + pendingFileProjections.mu.Lock() + events := entry.sourceEvents + pendingFileProjections.mu.Unlock() + t.Logf("actor_attempts=%d original_remaining=%s checked=%v suspect=%v source_events=%d", allowance.attempts.Load(), time.Until(deadline), entry.checkedRetired.Load(), entry.suspect.Load(), events) + require.Zero(t, events, "no source event happened in this controlled interval") + require.True(t, entry.checkedRetired.Load()) + require.False(t, entry.suspect.Load(), "an unchanged losing reader must not poison another reader's checked retirement") + require.ErrorIs(t, validateRequestProjections(proof), projectionSuperseded, "the old context remains invalid") + require.True(t, retryAfterCheckedProjectionChange(proof), "only a fresh bounded attempt may use the unchanged canonical proof") +} From 16bdefe1e32523647553d66d1c2e8ebd3edcd76c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 21:11:18 +0100 Subject: [PATCH 58/61] fix: keep unchanged read supersession separate from invalidation --- internal/mcp/projected_reads.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/mcp/projected_reads.go b/internal/mcp/projected_reads.go index 478bd02f8..ea04df5fb 100644 --- a/internal/mcp/projected_reads.go +++ b/internal/mcp/projected_reads.go @@ -268,7 +268,13 @@ func (s *Server) projectCurrentTrackedFile(buildCtx, requestCtx context.Context, rawHash := blake3.Sum256(raw) if expected != nil { if !expected.tombstone && expected.rawHash == rawHash && expected.disk.matches(after) && - pathkey.MutationKey(abs) == key && pendingFileProjections.snap.Load().entries[key] == expected { + pathkey.MutationKey(abs) == key { + // Ownership can move while metadata is read even though these + // bounded bytes did not change. Refuse this selection without + // inventing source invalidation on a checked historical owner. + if pendingFileProjections.snap.Load().entries[key] != expected { + return nil, projectionSuperseded + } if !expected.suspect.Load() { return expected, nil } From afebbf0b3376a439aaf5204d089b2eb4cefe1484 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 23:03:40 +0100 Subject: [PATCH 59/61] fix(mcp): retain bounded canonical rows after projection expiry --- internal/mcp/contract_core_editing_context.go | 10 ++--- ...tract_core_editing_context_context_test.go | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/internal/mcp/contract_core_editing_context.go b/internal/mcp/contract_core_editing_context.go index d3136023a..e931b3cfd 100644 --- a/internal/mcp/contract_core_editing_context.go +++ b/internal/mcp/contract_core_editing_context.go @@ -53,11 +53,11 @@ func fileEditingContextContext(ctx context.Context, reader graph.Reader, path st // withFileStoreRows gives every section of one file response the SAME 30ms // cancellable, coherent selected-store scope. No legacy API is a fallback. func withFileStoreRows(ctx context.Context, reader graph.Reader, consume func(graph.FileEditingContextRows) error) error { - deadline := time.Now().Add(30 * time.Millisecond) - if original, ok := ctx.Value(projectedSourceDeadlineKey{}).(time.Time); ok && original.Before(deadline) { - deadline = original - } - ctx, cancel := context.WithDeadline(ctx, deadline) + // Projection/whole-attempt retries have a separate 150ms allowance. Once + // it expires, a labelled pending reply may still read useful canonical + // rows. This scope earns no projection freshness or retry permission and + // remains bounded by both the caller's hard deadline and its own 30ms cap. + ctx, cancel := context.WithTimeout(ctx, 30*time.Millisecond) defer cancel() selected := reader var core *contractCoreEdges diff --git a/internal/mcp/contract_core_editing_context_context_test.go b/internal/mcp/contract_core_editing_context_context_test.go index aa55db2fa..99c9abbe6 100644 --- a/internal/mcp/contract_core_editing_context_context_test.go +++ b/internal/mcp/contract_core_editing_context_context_test.go @@ -3,6 +3,7 @@ package mcp import ( "context" "testing" + "time" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph" @@ -45,3 +46,44 @@ func TestEditingContextBundleContractFilterFullParity(t *testing.T) { require.NoError(t, err) require.Equal(t, want, got) } + +// A projection deadline limits work that may earn current-file freshness. It +// cannot erase useful canonical rows from a separately labelled pending reply. +func TestPendingFileStoreScopeDoesNotReviveProjectionAllowance(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "a.go::f", Kind: graph.KindFunction, FilePath: "a.go", StartLine: 3, EndLine: 4}) + expired := time.Now().Add(-time.Second) + allowance := &projectedSourceAllowance{tool: "get_file_summary"} + allowance.attempts.Store(2) + ctx := context.WithValue(t.Context(), projectedSourceDeadlineKey{}, expired) + ctx = context.WithValue(ctx, projectedSourceAllowanceKey{}, allowance) + calls := 0 + err := withFileStoreRows(ctx, g, func(rows graph.FileEditingContextRows) error { + calls++ + require.Len(t, rows.GetFileNodes("a.go"), 1) + require.Equal(t, expired, ctx.Value(projectedSourceDeadlineKey{})) + require.False(t, allowance.claim(), "canonical rows cannot authorize a third projection attempt") + return nil + }) + require.NoError(t, err) + require.Equal(t, 1, calls) + require.EqualValues(t, 2, allowance.attempts.Load()) + for _, cancelled := range []bool{false, true} { + var caller context.Context + var cancel context.CancelFunc + want := context.DeadlineExceeded + if cancelled { + caller, cancel = context.WithCancel(ctx) + cancel() + want = context.Canceled + } else { + caller, cancel = context.WithDeadline(ctx, time.Now().Add(-time.Millisecond)) + } + err := withFileStoreRows(caller, g, func(graph.FileEditingContextRows) error { + t.Error("a finished caller must not read canonical rows") + return nil + }) + require.ErrorIs(t, err, want) + cancel() + } +} From 1d6e1316ec181627c2bd31758c68bb2735e44b29 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 23:13:43 +0100 Subject: [PATCH 60/61] test(mcp): join canonical read callbacks on the test owner --- .../canonical_promotion_file_reads_test.go | 109 ++++++----- internal/mcp/canonical_promotion_read_test.go | 181 ++++++++++-------- internal/mcp/canonical_read_callback_test.go | 104 ++++++++++ ...registered_preselection_retirement_test.go | 32 +++- 4 files changed, 300 insertions(+), 126 deletions(-) create mode 100644 internal/mcp/canonical_read_callback_test.go diff --git a/internal/mcp/canonical_promotion_file_reads_test.go b/internal/mcp/canonical_promotion_file_reads_test.go index 87e111d63..660dd8a21 100644 --- a/internal/mcp/canonical_promotion_file_reads_test.go +++ b/internal/mcp/canonical_promotion_file_reads_test.go @@ -119,59 +119,80 @@ func TestCanonicalPromotionFileReadsKeepIndependentDefinitionOracle(t *testing.T calls := 0 var first context.Context cancelledLeafDone := make(chan struct{}) + var onOwner func(func()) bool leaf := func(ctx context.Context, request mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - calls++ - if transition == "cancelled_promotion" { - defer close(cancelledLeafDone) - } - require.Equal(t, deadline, ctx.Value(projectedSourceDeadlineKey{})) + var result *mcplib.CallToolResult var err error - if tool == "get_file_summary" { - result, err = s.handleGetFileSummary(ctx, request) - } else { - result, err = s.handleGetEditingContext(ctx, request) - } - require.NoError(t, err) - requireCanonicalDefinitionRows(t, tool, result, raw) - if calls != 1 { - return result, nil - } - first = ctx - require.True(t, s.hasRequestProjectionFact(ctx, "main.go")) - switch transition { - case "canonical_promotion", "compression_not_audited", "cancelled_promotion": - require.Equal(t, 5, s.graph.GetNode("main.go::Neighbor").StartLine) - require.NoError(t, s.indexer.IndexFile(path)) - require.Equal(t, 8, s.graph.GetNode("main.go::Neighbor").StartLine) - retirement.run() - require.True(t, entry.checkedRetired.Load()) - require.Nil(t, liveEntry(pendingFileProjections, path)) - require.True(t, time.Now().Before(deadline)) - case "peer_same_bytes": - held, release, err := acquireMutationPath(t.Context(), path) - require.NoError(t, err) - commit, writeErr := s.commitFileMutation(held, "write_file", "", "", "main.go", path, raw, 0600) - release() - require.NoError(t, writeErr) - require.Greater(t, commit.commitSeq, entry.commitSeq) - case "same_bytes_event": - pendingFileProjections.markSuspect(path) - case "unchecked_eviction": - pendingFileProjections.drop(entry) - require.False(t, entry.checkedRetired.Load()) - } - require.ErrorIs(t, validateRequestProjections(first), projectionSuperseded) - if transition == "cancelled_promotion" { - cancel() + if !onOwner(func() { + result, err = func() (*mcplib.CallToolResult, error) { + t.Logf("tool=%s stage=callback_entry remaining_original_budget=%s", tool, time.Until(deadline)) + leafStarted := time.Now() + calls++ + if transition == "cancelled_promotion" { + defer close(cancelledLeafDone) + } + require.Equal(t, deadline, ctx.Value(projectedSourceDeadlineKey{})) + var result *mcplib.CallToolResult + var err error + if tool == "get_file_summary" { + result, err = s.handleGetFileSummary(ctx, request) + } else { + result, err = s.handleGetEditingContext(ctx, request) + } + t.Logf("tool=%s stage=leaf_return elapsed=%s remaining_original_budget=%s", tool, time.Since(leafStarted), time.Until(deadline)) + require.NoError(t, err) + requireCanonicalDefinitionRows(t, tool, result, raw) + if calls != 1 { + return result, nil + } + first = ctx + require.True(t, s.hasRequestProjectionFact(ctx, "main.go")) + switch transition { + case "canonical_promotion", "compression_not_audited", "cancelled_promotion": + require.Equal(t, 5, s.graph.GetNode("main.go::Neighbor").StartLine) + publicationStarted := time.Now() + publicationErr := s.indexer.IndexFile(path) + t.Logf("publication elapsed=%s remaining_original_budget=%s", time.Since(publicationStarted), time.Until(deadline)) + require.NoError(t, publicationErr) + require.Equal(t, 8, s.graph.GetNode("main.go::Neighbor").StartLine) + retirement.run() + require.True(t, entry.checkedRetired.Load()) + require.Nil(t, liveEntry(pendingFileProjections, path)) + require.True(t, time.Now().Before(deadline)) + case "peer_same_bytes": + held, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + commit, writeErr := s.commitFileMutation(held, "write_file", "", "", "main.go", path, raw, 0600) + release() + require.NoError(t, writeErr) + require.Greater(t, commit.commitSeq, entry.commitSeq) + case "same_bytes_event": + pendingFileProjections.markSuspect(path) + case "unchecked_eviction": + pendingFileProjections.drop(entry) + require.False(t, entry.checkedRetired.Load()) + } + require.ErrorIs(t, validateRequestProjections(first), projectionSuperseded) + if transition == "cancelled_promotion" { + cancel() + } + return result, nil + }() + }) { + return mcplib.NewToolResultError("fixture callback aborted"), nil } - return result, nil + return result, err } args := map[string]any{"path": "main.go", "format": "json"} if transition == "compression_not_audited" { args["compress_bodies"] = true } - result, err := s.wrapToolHandler(leaf)(ctx, makeReq(tool, args)) + t.Logf("tool=%s stage=dispatch remaining_original_budget=%s", tool, time.Until(deadline)) + result, err := canonicalReadWithOwner(t, func(dispatch func(func()) bool) (*mcplib.CallToolResult, error) { + onOwner = dispatch + return s.wrapToolHandler(leaf)(ctx, makeReq(tool, args)) + }) if transition == "cancelled_promotion" { require.True(t, err != nil || result != nil && result.IsError) select { diff --git a/internal/mcp/canonical_promotion_read_test.go b/internal/mcp/canonical_promotion_read_test.go index 6b25b8c45..e19ee3ebb 100644 --- a/internal/mcp/canonical_promotion_read_test.go +++ b/internal/mcp/canonical_promotion_read_test.go @@ -91,86 +91,113 @@ func TestSymbolSourceCanonicalPromotionAfterLeafUsesBoundedFreshAttempt(t *testi calls := 0 var first context.Context var deadlines []time.Time + var onOwner func(func()) bool leaf := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - calls++ - deadlines = append(deadlines, ctx.Value(projectedSourceDeadlineKey{}).(time.Time)) - result, readErr := s.handleGetSymbolSource(ctx, req) - require.NoError(t, readErr) - require.NotNil(t, result) - require.False(t, result.IsError, resultText(result)) - require.Equal(t, body, extractTextResult(t, result)["source"]) - if calls != 1 { - return result, nil - } - first = ctx - require.True(t, s.hasRequestProjectionFact(ctx, "main.go"), "the real source handler must borrow the ACK owner before the transition") - switch transition { - case "canonical_promotion", "canonical_cancelled": - // Actual parser/store publication, not a fabricated Done result, - // matching hash alone, peer edit or manual registry deletion. - require.NoError(t, s.indexer.IndexFile(path)) - require.Equal(t, 8, store.GetNode("main.go::Neighbor").StartLine) - rows, metaErr := store.FileMetasByPaths(entry.repoPrefix, []string{entry.graphPath}) - require.NoError(t, metaErr) - require.Equal(t, entry.hash, rows[entry.graphPath].ContentHash) - retirement.run() - require.Nil(t, liveEntry(pendingFileProjections, path)) - require.Positive(t, pendingFileProjections.retired.Load()) - require.False(t, entry.suspect.Load(), "canonical promotion is not a native invalidation") - require.True(t, time.Now().Before(originalDeadline), "canonical retirement must fit the original read allowance") - require.True(t, entry.checkedRetired.Load()) - if transition == "canonical_cancelled" { - cancel() - } - case "receipt_only": - retirement.run() - require.Same(t, entry, liveEntry(pendingFileProjections, path), "an unadvanced canonical store cannot retire the acknowledged bytes") - require.Zero(t, pendingFileProjections.retired.Load()) - case "unchecked_eviction": - pendingFileProjections.drop(entry) - require.Nil(t, liveEntry(pendingFileProjections, path)) - require.False(t, entry.checkedRetired.Load()) - case "peer_same_bytes_commit", "peer_retired": - held, release, lockErr := acquireMutationPath(t.Context(), path) - require.NoError(t, lockErr) - commit, writeErr := s.commitFileMutation(held, "write_file", "", "", "main.go", path, after, 0600) - release() - require.NoError(t, writeErr) - require.Greater(t, commit.commitSeq, sequence) - require.NotSame(t, entry, liveEntry(pendingFileProjections, path)) - require.Equal(t, entry.rawHash, liveEntry(pendingFileProjections, path).rawHash) - if transition == "peer_retired" { - newer := liveEntry(pendingFileProjections, path) - require.NoError(t, s.indexer.IndexFile(path)) - retirement.run() - require.Nil(t, liveEntry(pendingFileProjections, path)) - require.True(t, newer.checkedRetired.Load()) - require.False(t, entry.checkedRetired.Load(), "another owner's retirement cannot authorize this old selection") - } - case "same_bytes_event", "event_then_retire": - pendingFileProjections.markSuspect(path) - require.Same(t, entry, liveEntry(pendingFileProjections, path)) - require.True(t, entry.suspect.Load()) - if transition == "event_then_retire" { - require.NoError(t, s.indexer.IndexFile(path)) - retirement.run() - require.Nil(t, liveEntry(pendingFileProjections, path)) - require.False(t, entry.checkedRetired.Load()) - } - } - require.Equal(t, sequence, entry.commitSeq) - require.Same(t, layer, entry.layer) - current, diskErr := os.ReadFile(path) - require.NoError(t, diskErr) - require.Equal(t, after, current, "the retirement/event controls must never introduce another physical edit") - if transition != "receipt_only" && transition != "canonical_cancelled" { - require.ErrorIs(t, validateRequestProjections(first), projectionSuperseded, "the old context must remain unusable; a fresh attempt cannot rebind its facts") + + var result *mcplib.CallToolResult + var err error + if !onOwner(func() { + result, err = func() (*mcplib.CallToolResult, error) { + t.Logf("tool=%s stage=callback_entry remaining_original_budget=%s", "get_symbol_source", time.Until(originalDeadline)) + leafStarted := time.Now() + calls++ + deadlines = append(deadlines, ctx.Value(projectedSourceDeadlineKey{}).(time.Time)) + result, readErr := s.handleGetSymbolSource(ctx, req) + t.Logf("tool=%s stage=leaf_return elapsed=%s remaining_original_budget=%s", "get_symbol_source", time.Since(leafStarted), time.Until(originalDeadline)) + require.NoError(t, readErr) + require.NotNil(t, result) + require.False(t, result.IsError, resultText(result)) + require.Equal(t, body, extractTextResult(t, result)["source"]) + if calls != 1 { + return result, nil + } + first = ctx + require.True(t, s.hasRequestProjectionFact(ctx, "main.go"), "the real source handler must borrow the ACK owner before the transition") + switch transition { + case "canonical_promotion", "canonical_cancelled": + // Actual parser/store publication, not a fabricated Done result, + // matching hash alone, peer edit or manual registry deletion. + publicationStarted := time.Now() + publicationErr := s.indexer.IndexFile(path) + t.Logf("publication elapsed=%s remaining_original_budget=%s", time.Since(publicationStarted), time.Until(originalDeadline)) + require.NoError(t, publicationErr) + require.Equal(t, 8, store.GetNode("main.go::Neighbor").StartLine) + rows, metaErr := store.FileMetasByPaths(entry.repoPrefix, []string{entry.graphPath}) + require.NoError(t, metaErr) + require.Equal(t, entry.hash, rows[entry.graphPath].ContentHash) + retirement.run() + require.Nil(t, liveEntry(pendingFileProjections, path)) + require.Positive(t, pendingFileProjections.retired.Load()) + require.False(t, entry.suspect.Load(), "canonical promotion is not a native invalidation") + require.True(t, time.Now().Before(originalDeadline), "canonical retirement must fit the original read allowance") + require.True(t, entry.checkedRetired.Load()) + if transition == "canonical_cancelled" { + cancel() + } + case "receipt_only": + retirement.run() + require.Same(t, entry, liveEntry(pendingFileProjections, path), "an unadvanced canonical store cannot retire the acknowledged bytes") + require.Zero(t, pendingFileProjections.retired.Load()) + case "unchecked_eviction": + pendingFileProjections.drop(entry) + require.Nil(t, liveEntry(pendingFileProjections, path)) + require.False(t, entry.checkedRetired.Load()) + case "peer_same_bytes_commit", "peer_retired": + held, release, lockErr := acquireMutationPath(t.Context(), path) + require.NoError(t, lockErr) + commit, writeErr := s.commitFileMutation(held, "write_file", "", "", "main.go", path, after, 0600) + release() + require.NoError(t, writeErr) + require.Greater(t, commit.commitSeq, sequence) + require.NotSame(t, entry, liveEntry(pendingFileProjections, path)) + require.Equal(t, entry.rawHash, liveEntry(pendingFileProjections, path).rawHash) + if transition == "peer_retired" { + newer := liveEntry(pendingFileProjections, path) + publicationStarted := time.Now() + publicationErr := s.indexer.IndexFile(path) + t.Logf("publication elapsed=%s remaining_original_budget=%s", time.Since(publicationStarted), time.Until(originalDeadline)) + require.NoError(t, publicationErr) + retirement.run() + require.Nil(t, liveEntry(pendingFileProjections, path)) + require.True(t, newer.checkedRetired.Load()) + require.False(t, entry.checkedRetired.Load(), "another owner's retirement cannot authorize this old selection") + } + case "same_bytes_event", "event_then_retire": + pendingFileProjections.markSuspect(path) + require.Same(t, entry, liveEntry(pendingFileProjections, path)) + require.True(t, entry.suspect.Load()) + if transition == "event_then_retire" { + publicationStarted := time.Now() + publicationErr := s.indexer.IndexFile(path) + t.Logf("publication elapsed=%s remaining_original_budget=%s", time.Since(publicationStarted), time.Until(originalDeadline)) + require.NoError(t, publicationErr) + retirement.run() + require.Nil(t, liveEntry(pendingFileProjections, path)) + require.False(t, entry.checkedRetired.Load()) + } + } + require.Equal(t, sequence, entry.commitSeq) + require.Same(t, layer, entry.layer) + current, diskErr := os.ReadFile(path) + require.NoError(t, diskErr) + require.Equal(t, after, current, "the retirement/event controls must never introduce another physical edit") + if transition != "receipt_only" && transition != "canonical_cancelled" { + require.ErrorIs(t, validateRequestProjections(first), projectionSuperseded, "the old context must remain unusable; a fresh attempt cannot rebind its facts") + } + return result, nil + }() + }) { + return mcplib.NewToolResultError("fixture callback aborted"), nil } - return result, nil + return result, err } - result, err := s.wrapToolHandler(leaf)(ctx, makeReq("get_symbol_source", map[string]any{ - "id": "main.go::Target", "context_lines": 0, "format": "json", - })) + t.Logf("tool=%s stage=dispatch remaining_original_budget=%s", "get_symbol_source", time.Until(originalDeadline)) + result, err := canonicalReadWithOwner(t, func(dispatch func(func()) bool) (*mcplib.CallToolResult, error) { + onOwner = dispatch + return s.wrapToolHandler(leaf)(ctx, makeReq("get_symbol_source", map[string]any{ + "id": "main.go::Target", "context_lines": 0, "format": "json", + })) + }) if transition == "canonical_cancelled" { require.True(t, err != nil || result != nil && result.IsError, "cancellation cannot become an accepted result") require.Equal(t, 1, calls) diff --git a/internal/mcp/canonical_read_callback_test.go b/internal/mcp/canonical_read_callback_test.go new file mode 100644 index 000000000..aeb2bdedc --- /dev/null +++ b/internal/mcp/canonical_read_callback_test.go @@ -0,0 +1,104 @@ +package mcp + +import ( + "context" + "errors" + "github.com/stretchr/testify/require" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" +) + +// canonicalReadWithOwner runs fixture callbacks on the test's goroutine. A +// fatal fixture assertion must terminate the test, not Goexit the handler and +// leave the production deadline firewall waiting for an outcome it cannot get. +// The handler still uses the real contexts, deadlines, middleware and leases. +func canonicalReadWithOwner(t *testing.T, invoke func(func(func()) bool) (*mcplib.CallToolResult, error)) (*mcplib.CallToolResult, error) { + t.Helper() + type callback struct { + run func() + done chan struct{} + } + type reply struct { + result *mcplib.CallToolResult + err error + } + callbacks := make(chan callback) + stopped := make(chan struct{}) + finished := make(chan reply, 1) + joined := false + defer func() { + close(stopped) + if !joined { + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned fixture call did not join after callback assertion failed") + } + } + }() + go func() { + result, err := invoke(func(run func()) bool { + call := callback{run: run, done: make(chan struct{})} + select { + case callbacks <- call: + case <-stopped: + return false + } + select { + case <-call.done: + return true + case <-stopped: + return false + } + }) + finished <- reply{result, err} + }() + for { + select { + case call := <-callbacks: + call.run() + close(call.done) + case got := <-finished: + joined = true + return got.result, got.err + } + } +} + +func TestCanonicalReadCallbackFatalDoesNotStrandHandler(t *testing.T) { + const markerEnv = "GORTEX_CANONICAL_CALLBACK_JOIN_TEST" + if marker := os.Getenv(markerEnv); marker != "" { + _, _ = canonicalReadWithOwner(t, func(onOwner func(func()) bool) (*mcplib.CallToolResult, error) { + if onOwner(func() { t.Fatal("controlled owning-test callback failure") }) { + return mcplib.NewToolResultError("fatal callback returned"), nil + } + // This runs only after the owner's fatal assertion stopped the callback. + // The helper must join this completion before test-process cleanup exits. + if err := os.WriteFile(marker, []byte("handler joined"), 0600); err != nil { + return nil, err + } + return mcplib.NewToolResultError("fixture callback aborted"), nil + }) + return + } + marker := filepath.Join(t.TempDir(), "handler-joined") + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + executable, err := os.Executable() + require.NoError(t, err) + child := exec.CommandContext(ctx, executable, "-test.run=^TestCanonicalReadCallbackFatalDoesNotStrandHandler$") + child.Env = append(os.Environ(), markerEnv+"="+marker) + output, err := child.CombinedOutput() + require.NoError(t, ctx.Err(), "fatal fixture callback must not wait for the production firewall: %s", output) + var exit *exec.ExitError + require.True(t, errors.As(err, &exit), "the child's controlled test assertion must fail: %s", output) + require.Contains(t, string(output), "controlled owning-test callback failure") + joined, err := os.ReadFile(marker) + require.NoError(t, err, "the aborted callback's handler must finish before the failed test exits") + require.Equal(t, "handler joined", string(joined)) +} diff --git a/internal/mcp/registered_preselection_retirement_test.go b/internal/mcp/registered_preselection_retirement_test.go index e8a59ee20..b1b0d2a0d 100644 --- a/internal/mcp/registered_preselection_retirement_test.go +++ b/internal/mcp/registered_preselection_retirement_test.go @@ -44,8 +44,12 @@ func (s *preselectionRetirementStore) FileMetasByPaths(prefix string, paths []st // before registered MCP dispatch, not a direct leaf call. func preselectionRegisteredCall(t *testing.T, s *Server, ctx context.Context, id int, name string, args map[string]any) *mcplib.CallToolResult { t.Helper() + started := time.Now() require.True(t, s.IsToolEnabledForSession(ctx, name), "the entire initialized connection uses all/defer") s.EnsureToolPromotedForSession(ctx, name) + if deadline, ok := ctx.Value(projectedSourceDeadlineKey{}).(time.Time); ok { + t.Logf("registered tool=%s stage=before_sdk_dispatch authorization_and_promotion=%s remaining_original_budget=%s", name, time.Since(started), time.Until(deadline)) + } return guardE2ECall(t, s, WithAuthorizedToolCall(ctx, name), id, name, args) } @@ -115,19 +119,37 @@ func TestRegisteredPreselectionRetirementKeepsCurrentFileRows(t *testing.T) { } retirement := s.canonicalProjectionRetirement(path) require.NotNil(t, retirement) + var beforeNode, afterNode *graph.Node + var publicationErr error + var publicationElapsed time.Duration + var publicationFinished, callbackEntered time.Time + var checkedRetired bool + var current *projectionEntry backend.publish = func() { - require.Equal(t, 5, sql.GetNode("main.go::Neighbor").StartLine) - require.NoError(t, idx.IndexFile(path)) - require.Equal(t, 8, sql.GetNode("main.go::Neighbor").StartLine) + callbackEntered = time.Now() + beforeNode = sql.GetNode("main.go::Neighbor") + started := time.Now() + publicationErr = idx.IndexFile(path) + publicationElapsed = time.Since(started) + afterNode = sql.GetNode("main.go::Neighbor") retirement.run() - require.True(t, entry.checkedRetired.Load()) - require.Nil(t, liveEntry(pendingFileProjections, path)) + checkedRetired = entry.checkedRetired.Load() + current = liveEntry(pendingFileProjections, path) + publicationFinished = time.Now() } deadline := time.Now().Add(projectedReadBudget) ctx = context.WithValue(ctx, projectedSourceDeadlineKey{}, deadline) backend.armed.Store(true) result := preselectionRegisteredCall(t, s, ctx, 20, tool, map[string]any{"path": "main.go", "format": "json"}) require.Equal(t, int32(1), backend.reads.Load(), "actual preselection metadata seam must be reached") + t.Logf("registered stage=callback_entry remaining_original_budget=%s publication_elapsed=%s remaining_at_publication=%s", deadline.Sub(callbackEntered), publicationElapsed, deadline.Sub(publicationFinished)) + require.NotNil(t, beforeNode) + require.Equal(t, 5, beforeNode.StartLine) + require.NoError(t, publicationErr) + require.NotNil(t, afterNode) + require.Equal(t, 8, afterNode.StartLine) + require.True(t, checkedRetired) + require.Nil(t, current) t.Logf("registered tool=%s result=%s remaining_original_budget=%s", tool, resultText(result), time.Until(deadline)) requireCanonicalDefinitionRows(t, tool, result, raw) }) From 13e726cbfa93fd5888b4d205d917618fde8da27b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Tue, 3 Nov 2026 00:25:24 +0100 Subject: [PATCH 61/61] test(mcp): isolate SQL read interleavings and caller deadlines --- internal/mcp/canonical_file_read_budget_test.go | 15 ++++++++++++--- internal/mcp/canonical_promotion_read_test.go | 5 ++++- internal/mcp/read_retirement_poison_test.go | 4 +++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/internal/mcp/canonical_file_read_budget_test.go b/internal/mcp/canonical_file_read_budget_test.go index 61d1896fe..be81447c5 100644 --- a/internal/mcp/canonical_file_read_budget_test.go +++ b/internal/mcp/canonical_file_read_budget_test.go @@ -21,9 +21,15 @@ func (r *remainingFileReadBudget) WithFileEditingContextReader(ctx context.Conte return ctx.Err() } -func TestFileStoreScopeCannotRenewOriginalProjectionDeadline(t *testing.T) { +func TestFileStoreScopeCannotRenewOriginalCallerDeadline(t *testing.T) { deadline := time.Now().Add(10 * time.Millisecond) - ctx := context.WithValue(t.Context(), projectedSourceDeadlineKey{}, deadline) + caller, cancel := context.WithDeadline(t.Context(), deadline) + defer cancel() + projectionDeadline := time.Now().Add(-time.Second) + ctx := context.WithValue(caller, projectedSourceDeadlineKey{}, projectionDeadline) + allowance := &projectedSourceAllowance{tool: "get_file_summary"} + allowance.attempts.Store(2) + ctx = context.WithValue(ctx, projectedSourceAllowanceKey{}, allowance) reader := &remainingFileReadBudget{Store: graph.New()} consumed := false err := withFileStoreRows(ctx, reader, func(graph.FileEditingContextRows) error { @@ -31,6 +37,9 @@ func TestFileStoreScopeCannotRenewOriginalProjectionDeadline(t *testing.T) { return nil }) require.ErrorIs(t, err, context.DeadlineExceeded) - require.Equal(t, deadline, reader.observed, "a second 30ms store scope cannot extend the original remaining allowance") + require.Equal(t, deadline, reader.observed, "a 30ms canonical SQL scope cannot extend the caller's original hard deadline") + require.Equal(t, projectionDeadline, ctx.Value(projectedSourceDeadlineKey{})) + require.False(t, allowance.claim(), "canonical SQL cannot revive an exhausted projection allowance") + require.EqualValues(t, 2, allowance.attempts.Load()) require.False(t, consumed, "an unavailable scope cannot supply partial canonical rows") } diff --git a/internal/mcp/canonical_promotion_read_test.go b/internal/mcp/canonical_promotion_read_test.go index e19ee3ebb..124331971 100644 --- a/internal/mcp/canonical_promotion_read_test.go +++ b/internal/mcp/canonical_promotion_read_test.go @@ -26,7 +26,10 @@ func canonicalPromotionReadFixture(t *testing.T) (*Server, *store_sqlite.Store, root := t.TempDir() path := filepath.Join(root, "main.go") require.NoError(t, os.WriteFile(path, []byte("package probe\n\nfunc Target() int { return 1 }\n\nfunc Neighbor() int { return 9 }\n"), 0600)) - store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "canonical.sqlite")) + // This is a real SQLite publication/reader interleaving, not a persistence + // benchmark. Keep temporary-filesystem I/O outside the 150ms read oracle; + // the actual IndexFile, SQL rows, retirement and deadline checks stay intact. + store, err := store_sqlite.Open(":memory:") require.NoError(t, err) t.Cleanup(func() { require.NoError(t, store.Close()) }) idx := newProjectionTestIndexer(t, store) diff --git a/internal/mcp/read_retirement_poison_test.go b/internal/mcp/read_retirement_poison_test.go index 33648539a..1246e8993 100644 --- a/internal/mcp/read_retirement_poison_test.go +++ b/internal/mcp/read_retirement_poison_test.go @@ -26,7 +26,9 @@ func TestUnchangedReadAfterRetirementCannotPoisonBorrowedRetryProof(t *testing.T root := t.TempDir() path := filepath.Join(root, "main.go") require.NoError(t, os.WriteFile(path, []byte("package probe\n\nfunc Target() int { return 1 }\n\nfunc Neighbor() int { return 9 }\n"), 0600)) - sql, err := store_sqlite.Open(filepath.Join(t.TempDir(), "retirement.sqlite")) + // Exercise the real SQLite publication/reader boundary without charging + // temporary-filesystem persistence to this 150ms interleaving oracle. + sql, err := store_sqlite.Open(":memory:") require.NoError(t, err) t.Cleanup(func() { require.NoError(t, sql.Close()) }) backend := &preselectionRetirementStore{Store: sql}