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/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/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) + } +} 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/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/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") +} diff --git a/internal/indexer/watcher_storm_observation.go b/internal/indexer/watcher_storm_observation.go new file mode 100644 index 000000000..37e356724 --- /dev/null +++ b/internal/indexer/watcher_storm_observation.go @@ -0,0 +1,51 @@ +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) +} diff --git a/internal/indexer/watcher_storm_observation_test.go b/internal/indexer/watcher_storm_observation_test.go new file mode 100644 index 000000000..8522d89ce --- /dev/null +++ b/internal/indexer/watcher_storm_observation_test.go @@ -0,0 +1,103 @@ +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)) +} + +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") +} 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..567a1cf5d --- /dev/null +++ b/internal/mcp/cached_ack_source_read_test.go @@ -0,0 +1,158 @@ +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) { + 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) { + 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") +} 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..be81447c5 --- /dev/null +++ b/internal/mcp/canonical_file_read_budget_test.go @@ -0,0 +1,45 @@ +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 TestFileStoreScopeCannotRenewOriginalCallerDeadline(t *testing.T) { + deadline := time.Now().Add(10 * time.Millisecond) + 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 { + consumed = true + return nil + }) + require.ErrorIs(t, err, context.DeadlineExceeded) + 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_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_file_reads_test.go b/internal/mcp/canonical_promotion_file_reads_test.go new file mode 100644 index 000000000..660dd8a21 --- /dev/null +++ b/internal/mcp/canonical_promotion_file_reads_test.go @@ -0,0 +1,241 @@ +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 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"} { + 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) + 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{}) + var onOwner func(func()) bool + leaf := func(ctx context.Context, request mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + + 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", 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, err + } + args := map[string]any{"path": "main.go", "format": "json"} + if transition == "compression_not_audited" { + args["compress_bodies"] = true + } + 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 { + 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] + 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") + 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/canonical_promotion_read_test.go b/internal/mcp/canonical_promotion_read_test.go new file mode 100644 index 000000000..124331971 --- /dev/null +++ b/internal/mcp/canonical_promotion_read_test.go @@ -0,0 +1,243 @@ +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" + "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)) + // 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) + _, 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", "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) + 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) + caller, cancel := context.WithCancel(t.Context()) + defer cancel() + ctx := context.WithValue(caller, projectedSourceDeadlineKey{}, originalDeadline) + 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) { + + 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, err + } + 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) + 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) + 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"]) + 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) + 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) + } + }) + } +} 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/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/contract_core_capability_table_test.go b/internal/mcp/contract_core_capability_table_test.go index 6f03bc09c..8486a8901 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"}, @@ -422,6 +424,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..e931b3cfd 100644 --- a/internal/mcp/contract_core_editing_context.go +++ b/internal/mcp/contract_core_editing_context.go @@ -1,6 +1,13 @@ package mcp -import "github.com/zzet/gortex/internal/graph" +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 // a contract-core wrapper. The selected reader's own FileEditingContext reads @@ -27,3 +34,236 @@ 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) { + 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 { + // 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 + if wrapped, ok := reader.(contractCoreWrapped); ok { + core = wrapped.contractCore() + selected = core.Reader + } + err := graph.WithFileEditingContextReader(ctx, selected, func(_ context.Context, rows graph.FileEditingContextRows) error { + if core != nil { + rows = &editingContextCoreRows{FileEditingContextRows: rows, core: core} + } + return consume(rows) + }) + if err != nil && ctx.Err() != nil { + return ctx.Err() + } + return err +} + +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)) +} + +// 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 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) { + selected = layer + } + } + if editor := OverlayViewFromContext(ctx); editor != nil { + if layer := editor.Layer(); layer != nil && layer.HasFile(path) { + 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 + } + 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 +} + +// 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/contract_core_editing_context_context_test.go b/internal/mcp/contract_core_editing_context_context_test.go new file mode 100644 index 000000000..99c9abbe6 --- /dev/null +++ b/internal/mcp/contract_core_editing_context_context_test.go @@ -0,0 +1,89 @@ +package mcp + +import ( + "context" + "testing" + "time" + + "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) +} + +// 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() + } +} 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/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/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..e41f274db 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,9 @@ 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) + 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]) } @@ -595,11 +615,55 @@ 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) + } + 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 + } + } + 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 { @@ -695,13 +759,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 +819,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 +838,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 +847,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 +1116,14 @@ 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 + } if f.state == "" { return } @@ -1041,6 +1131,12 @@ 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 + } + if f.state == "pending" && f.storeBusy { + result["reason"] = "store_busy" + } scope := f.scope if scope == "" { scope = "relations" @@ -1054,7 +1150,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 0edc90a22..64e8642cf 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/node_source.go b/internal/mcp/node_source.go index d29200d4b..109e20ae3 100644 --- a/internal/mcp/node_source.go +++ b/internal/mcp/node_source.go @@ -5,14 +5,16 @@ import ( "context" "errors" "fmt" - "os" + "path/filepath" "slices" "strings" + "time" "github.com/zeebo/blake3" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/indexer/source" "github.com/zzet/gortex/internal/pathkey" ) @@ -36,7 +38,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. @@ -122,19 +124,63 @@ 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 } - raw, err := func() ([]byte, error) { + owner, _ := s.indexerForRel(node.FilePath) + if owner == nil { + return nodeSource{}, fmt.Errorf("no indexer owns %s", 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) + if err != nil { + return nodeSource{}, err + } + files, err := source.NewFilesystemSource(root) + if err != nil { + return nodeSource{}, err + } + defer files.Close() + raw, _, err := func() ([]byte, source.FileMeta, error) { release := s.beginEditedFileRead(ctx, absPath) defer release() - return os.ReadFile(absPath) + return 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 + 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 { + 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/overlay.go b/internal/mcp/overlay.go index f47f35ca8..34b678ef0 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. @@ -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 { @@ -356,6 +357,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() @@ -410,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) @@ -443,6 +445,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 auditedProjectionRead && res != nil && !res.IsError && retryAfterCheckedProjectionChange(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 — @@ -451,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 retryAfterCheckedProjectionChange(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 @@ -474,7 +491,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/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 db21315dd..968aeaa20 100644 --- a/internal/mcp/pending_projection.go +++ b/internal/mcp/pending_projection.go @@ -58,14 +58,17 @@ 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 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 @@ -82,7 +85,10 @@ 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 + // 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. @@ -172,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, + 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, } } @@ -193,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, } @@ -211,13 +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, - 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, } } @@ -232,6 +245,49 @@ 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 { + 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 @@ -240,10 +296,10 @@ func (p *pendingProjections) publish(e *projectionEntry) error { } 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 && prev.commitSeq > e.commitSeq: - refusal = projectionSuperseded case prev == nil && len(cur.entries) >= projectionMaxFiles: refusal = projectionOverFiles case bytes > projectionMaxBytes: @@ -251,15 +307,48 @@ func (p *pendingProjections) publish(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 @@ -305,7 +394,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) } } @@ -322,6 +414,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 @@ -342,6 +439,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 { @@ -372,11 +484,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 { @@ -384,7 +499,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) @@ -396,15 +513,38 @@ 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) } } - retired := p.dropAll(caughtUp) + // 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.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_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") +} 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..4a32f6c57 --- /dev/null +++ b/internal/mcp/projected_read_event_revision_test.go @@ -0,0 +1,154 @@ +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) + 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() + <-joined + select { + case <-extractor.entered: + <-extractor.finished + default: + } + }) + 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(): + <-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) + 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(): + 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(): + <-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)) +} 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..50ec6c235 --- /dev/null +++ b/internal/mcp/projected_read_frontend_contract_test.go @@ -0,0 +1,181 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "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 + 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"}) + if err != nil { + return nil, err + } + if changed.CompareAndSwap(false, true) { + originalContext = ctx + 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") + 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) { + 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") + }) + } +} + +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.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()) + } 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") + } + }) + } +} 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..9bd664218 --- /dev/null +++ b/internal/mcp/projected_read_publication_test.go @@ -0,0 +1,164 @@ +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.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) { + 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_review_regression_test.go b/internal/mcp/projected_read_review_regression_test.go new file mode 100644 index 000000000..09dd61030 --- /dev/null +++ b/internal/mcp/projected_read_review_regression_test.go @@ -0,0 +1,203 @@ +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)} + // 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") + 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") + } + 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, "committed", ack["disk_status"]) + 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_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..ea04df5fb --- /dev/null +++ b/internal/mcp/projected_reads.go @@ -0,0 +1,566 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "maps" + "path/filepath" + "sync" + "time" + + "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" +) + +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 + } + allowance, audited := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance) + 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 +// 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 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) { + if requestReadsCommittedTree(ctx) || !requestViewFromContext(ctx).acceptsBufferOverlay() { + return ctx, nil + } + ctx = withRequestProjectionFacts(ctx) + if err := validateRequestProjections(ctx); err != nil { + return ctx, err + } + 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 { + if err := buildCtx.Err(); err != nil { + 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 + // 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 { + continue + } + abs, err := s.resolveGraphPath(ctx, graphPath) + if err != nil { + return ctx, err + } + 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 + } + 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 { + 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 { + // 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 { + if ctx.Err() != nil { + return ctx, ctx.Err() + } + markRequestProjectionDeferred(ctx, graphPath, "external_write") + continue + } + 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 + } + 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 +} + +// 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)) +} + +// 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 + } + rel, ok := relativeWithinRoot(root, abs) + if !ok { + return "", "", "", errors.New("file is outside the selected checkout") + } + key := pathkey.MutationKey(abs) + if !pathguard.WithinRoot(key, pathkey.IdentityKey(pathguard.ResolveRoot(root))) { + 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) + tracked := idx.IsTracked(rel) + root, readPath, key, err := selectedProjectionReadPath(requestCtx, idx.RootPath(), idx.RepoPrefix(), abs) + if err != nil { + return nil, err + } + 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 + } + if before.info == nil { + if expected != nil && expected.tombstone && liveProjectionForAcknowledgement(expected) { + return expected, nil + } + 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 { + return nil, errors.Join(err, errProjectionExternalWrite) + } + if err := pendingFileProjections.publishReadExpected(entry, observed); 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, + // symlink or unsupported platform is a refusal, never an unsafe Open. + files, err := source.NewFilesystemSource(root) + if err != nil { + return nil, err + } + defer files.Close() + 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 !tracked && !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) + } + 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 { + // 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 + } + // 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 advance its captured revision; + // 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) + if err != nil { + return nil, err + } + if !tracked && !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) { + 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.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) + } + if err := pendingFileProjections.publishReadExpected(entry, observed); err != nil { + return nil, err + } + s.admitReadBuiltProjection(requestCtx, root, idx, tracked, entry) + 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 + 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 { + if _, ok := ctx.Value(requestProjectionFactsKey{}).(*requestProjectionFacts); ok { + return ctx + } + 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) { + 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.storeBusyFiles == nil { + facts.storeBusyFiles = make(map[string]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 requestStoreBusyFiles(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.storeBusyFiles { + 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) { + 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", "search_symbols", "read_file": + 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/projected_source_retry.go b/internal/mcp/projected_source_retry.go new file mode 100644 index 000000000..b4d0fd792 --- /dev/null +++ b/internal/mcp/projected_source_retry.go @@ -0,0 +1,191 @@ +package mcp + +import ( + "context" + "errors" + "sync/atomic" + "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 { + pendingFileProjections.invalidateCurrent(entry) + return errProjectedSourceMoved +} + +type projectedSourceDeadlineKey struct{} +type projectedSourceAllowanceKey struct{} +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) + // 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 { + 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{tool: legacy}) + } + 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 + } + } +} + +// 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 + } + allowance, ok := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance) + if !ok || allowance.tool == "get_symbol" { + 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() + changed := false + for _, entry := range facts.entries { + 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] + 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 !retired && !readCOW { + return false + } + changed = true + } + return changed +} + +// 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 { + return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + allowance, ok := ctx.Value(projectedSourceAllowanceKey{}).(*projectedSourceAllowance) + if !ok { + return attempt(ctx, req) + } + for { + if !allowance.claim() { + return mcplib.NewToolResultError(errProjectedSourceMoved.Error()), nil + } + 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 ctx.Value(requestProjectionFactsKey{}) != nil { + return mcplib.NewToolResultError(errProjectedSourceMoved.Error()), nil + } + } + } +} + +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_storm_read_test.go b/internal/mcp/projected_storm_read_test.go new file mode 100644 index 000000000..b6543d1b9 --- /dev/null +++ b/internal/mcp/projected_storm_read_test.go @@ -0,0 +1,65 @@ +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")) +} + +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/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/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) +} diff --git a/internal/mcp/read_file_compression.go b/internal/mcp/read_file_compression.go new file mode 100644 index 000000000..c8b5a7276 --- /dev/null +++ b/internal/mcp/read_file_compression.go @@ -0,0 +1,73 @@ +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() + } + 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() + 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 { + return nil, false + } + after, stillKnown := baseRevision() + if !stillKnown || after != before || pendingFileProjections.snap.Load() != owners { + return nil, false + } + return nodes, true +} 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..a5c0cb396 --- /dev/null +++ b/internal/mcp/read_file_compression_gen0_test.go @@ -0,0 +1,85 @@ +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.Generation, "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) + 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) + } + } + _, 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") + }) + } +} diff --git a/internal/mcp/read_file_compression_test.go b/internal/mcp/read_file_compression_test.go new file mode 100644 index 000000000..7f71d9bdd --- /dev/null +++ b/internal/mcp/read_file_compression_test.go @@ -0,0 +1,97 @@ +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 + switch mode { + case "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) + } + case "same_bytes_new_owner", "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.publish(published)) + 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/read_path_guard_test.go b/internal/mcp/read_path_guard_test.go new file mode 100644 index 000000000..3795aa56b --- /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 = 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 +// 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" })) +} 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/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 new file mode 100644 index 000000000..fe84b8d04 --- /dev/null +++ b/internal/mcp/read_projection_admission_test.go @@ -0,0 +1,103 @@ +package mcp + +import ( + "context" + "errors" + "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") + _, 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") + }) + } +} + +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/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/read_retirement_poison_test.go b/internal/mcp/read_retirement_poison_test.go new file mode 100644 index 000000000..1246e8993 --- /dev/null +++ b/internal/mcp/read_retirement_poison_test.go @@ -0,0 +1,107 @@ +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)) + // 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} + 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") +} diff --git a/internal/mcp/registered_preselection_retirement_test.go b/internal/mcp/registered_preselection_retirement_test.go new file mode 100644 index 000000000..b1b0d2a0d --- /dev/null +++ b/internal/mcp/registered_preselection_retirement_test.go @@ -0,0 +1,157 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/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 +} + +// 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() + 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) +} + +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) }) + 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") + 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"]) + 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 := preselectionRegisteredCall(t, s, ctx, 11+i, name, args) + require.False(t, result.IsError, resultText(result)) + } + 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() { + 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() + 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) + }) + } +} 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") + } + }) + } +} 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..e011b418d --- /dev/null +++ b/internal/mcp/registered_same_owner_cow_test.go @@ -0,0 +1,336 @@ +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 + 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) <= s.gateReads { + 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) { + 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, false) +} + +// 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 { + 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() + 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, gateReads), release: make(chan struct{}), path: path, gateReads: gateReads} + 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 + 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 + 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}" + 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"]) + 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) + 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(!afterBorrow) + release := sync.OnceFunc(func() { close(backend.release) }) + type answer struct { + tool string + result *mcplib.CallToolResult + } + answers := make(chan answer, len(tools)) + allowances := make(map[string]*projectedSourceAllowance) + var workers sync.WaitGroup + t.Cleanup(func() { release(); releaseBorrowedOnce(); workers.Wait() }) + for i, tool := range tools { + if tool != "read_file" { + allowances[tool] = &projectedSourceAllowance{tool: tool} + } + allowance := allowances[tool] + 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"} + } + 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} + }() + } + 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() + } + results := map[string]*mcplib.CallToolResult{} + for range tools { + 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 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"] + 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"]) + 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"]) + for _, tool := range []string{"get_file_summary", "get_editing_context"} { + 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/relation_freshness.go b/internal/mcp/relation_freshness.go index e97072bc0..8cb2790aa 100644 --- a/internal/mcp/relation_freshness.go +++ b/internal/mcp/relation_freshness.go @@ -27,10 +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 { - 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 @@ -248,7 +252,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..fceae34c8 --- /dev/null +++ b/internal/mcp/selected_projection_retirement_test.go @@ -0,0 +1,460 @@ +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(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 + _, err := stack.srv.ensureProjected(ctx, []string{entry.graphPath}) + 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") +} + +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/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/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_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 b30f55b0e..7a54e235d 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -365,182 +365,109 @@ 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 { + if errors.Is(err, errProjectedSourceMoved) { + return nil, err + } + 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 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 - } + bundle, bundleErr := fileEditingContextContext(ctx, s.readerFor(ctx), fp, []graph.NodeKind{graph.KindFunction, graph.KindMethod}) + 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 !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 fileNodeForScope != nil && !s.nodeInSessionScope(ctx, fileNodeForScope) { + return mcp.NewToolResultError("no symbols found for file: " + fp), nil + } + 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 fileNodeForScope == nil { - fileNodeForScope = sg.Nodes[0] + }) + 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, @@ -620,7 +547,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 } @@ -947,13 +874,28 @@ 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 { + if errors.Is(err, errProjectedSourceMoved) { + return nil, err + } + return mcp.NewToolResultError(err.Error()), nil + } } 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 — @@ -1024,6 +966,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 b4947d818..77079f1a8 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" @@ -1723,13 +1724,25 @@ 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 { + if errors.Is(err, errProjectedSourceMoved) { + return nil, err + } + return mcp.NewToolResultError(err.Error()), nil + } } 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 } @@ -2753,25 +2766,27 @@ 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}) - - // 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. - 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) + ctx, err = s.ensureProjected(ctx, []string{fp}) + if err != nil { + if errors.Is(err, errProjectedSourceMoved) { + return nil, err + } + return mcp.NewToolResultError(err.Error()), nil } - if len(sg.Nodes) == 0 { + + // 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) + 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 @@ -2785,7 +2800,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 @@ -2801,7 +2816,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 @@ -2816,7 +2831,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 } @@ -2853,7 +2868,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)) } 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) { diff --git a/internal/mcp/tools_fileops.go b/internal/mcp/tools_fileops.go index a2232ca0f..c4ea8998c 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" ) @@ -771,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 @@ -1686,6 +1691,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 } @@ -1708,6 +1714,9 @@ 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 { @@ -1715,18 +1724,47 @@ 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 { + var entry *projectionEntry + 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 + } + } + 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 // 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 } + 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) { @@ -1810,6 +1848,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 { diff --git a/internal/mcp/untracked_projected_read_test.go b/internal/mcp/untracked_projected_read_test.go new file mode 100644 index 000000000..ab45c6b98 --- /dev/null +++ b/internal/mcp/untracked_projected_read_test.go @@ -0,0 +1,139 @@ +package mcp + +import ( + "context" + "github.com/zzet/gortex/internal/pathkey" + "os" + "path/filepath" + "strings" + "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") + 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) +}